Добавил каркас приложения
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Канонический деплой кладёт в build-контекст только готовый бинарь.
|
||||
*
|
||||
!jellybit
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Сборка
|
||||
/jellybit
|
||||
/dist/
|
||||
|
||||
# Реальный конфиг (секреты) и локальная БД
|
||||
/config.toml
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# IDE
|
||||
/.idea/
|
||||
/.vscode/
|
||||
@@ -0,0 +1,13 @@
|
||||
# Минимальный конфиг golangci-lint (формат v1).
|
||||
# При golangci-lint v2 может потребоваться адаптация схемы.
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- govet
|
||||
- ineffassign
|
||||
- staticcheck
|
||||
- unused
|
||||
- misspell
|
||||
@@ -54,11 +54,15 @@
|
||||
|
||||
## Команды
|
||||
|
||||
Кода ещё нет (фаза каркаса). По мере появления Ф0:
|
||||
- `make run` — локальный запуск (`go run ./cmd/jellybit --config ./config.toml`)
|
||||
- `make build` — статический бинарь `linux/amd64` для сервера
|
||||
- `make test` / `make lint` — тесты и golangci-lint
|
||||
- `make tidy` — `go mod tidy`
|
||||
- `make image` — docker-образ из готового бинаря
|
||||
|
||||
- сборка: `go build ./cmd/jellybit`
|
||||
- тесты: `go test ./...`
|
||||
- линт: `golangci-lint run`
|
||||
Module path — `git.vakhrushev.me/av/jellybit`. Go 1.23, `CGO_ENABLED=0`.
|
||||
Стек: `chi`, `sqlx` + `modernc.org/sqlite`, `goose` (миграции),
|
||||
`pelletier/go-toml/v2`, `log/slog`.
|
||||
|
||||
## Конвенции кода
|
||||
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Упаковка готового статического бинаря в минимальный образ.
|
||||
# Бинарь собирается снаружи (см. docs/adr/ADR-2026-06-13-docker-deploy.md):
|
||||
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit
|
||||
# distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь
|
||||
# задаётся в compose (user: "1000:1000").
|
||||
FROM gcr.io/distroless/static-debian12
|
||||
|
||||
COPY jellybit /usr/local/bin/jellybit
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/data/config.toml"]
|
||||
@@ -0,0 +1,28 @@
|
||||
BINARY := jellybit
|
||||
PKG := ./cmd/jellybit
|
||||
|
||||
.PHONY: build run test lint tidy image clean
|
||||
|
||||
# Статический бинарь для сервера (Intel N150 = linux/amd64).
|
||||
build:
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o $(BINARY) $(PKG)
|
||||
|
||||
# Локальный запуск (нужен ./config.toml с db_path -> ./jellybit.db).
|
||||
run:
|
||||
go run $(PKG) --config ./config.toml
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
lint:
|
||||
golangci-lint run
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
# Образ из уже собранного бинаря (см. docs/adr docker-deploy).
|
||||
image: build
|
||||
docker build -t jellybit:dev .
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
@@ -32,8 +32,9 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
|
||||
|
||||
## Статус
|
||||
|
||||
Ранняя разработка. Сейчас зафиксированы архитектура и решения, кода ещё
|
||||
нет. См. [дорожную карту](docs/drafts/roadmap.md).
|
||||
Ранняя разработка. Готов каркас (Ф0): загрузка TOML-конфига, SQLite +
|
||||
миграции, slog-логи, HTTP-сервер с `/healthz`. Дальше — приём загрузок и
|
||||
трекинг (Ф1). См. [дорожную карту](docs/drafts/roadmap.md).
|
||||
|
||||
## Документация
|
||||
|
||||
@@ -44,10 +45,22 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
|
||||
|
||||
## Стек
|
||||
|
||||
Go (один статический бинарь), SQLite, конфигурация — TOML, логи —
|
||||
структурированный JSON. Подробнее — в
|
||||
Go (один статический бинарь), SQLite (`modernc.org/sqlite` + `sqlx`,
|
||||
миграции `goose`), HTTP — `chi` + `html/template` + htmx, конфигурация —
|
||||
TOML, логи — структурированный JSON (`slog`). Подробнее — в
|
||||
[architecture.md](docs/specs/architecture.md).
|
||||
|
||||
## Разработка
|
||||
|
||||
```bash
|
||||
cp config.example.toml config.toml # локально: db_path -> ./jellybit.db
|
||||
make tidy # go mod tidy
|
||||
make run # go run ./cmd/jellybit --config ./config.toml
|
||||
make test lint # тесты и golangci-lint
|
||||
make build # статический бинарь (linux/amd64) для сервера
|
||||
make image # docker-образ из готового бинаря
|
||||
```
|
||||
|
||||
## Доставка
|
||||
|
||||
Сборка здесь → готовый бинарь копируется на медиа-сервер umbar
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Команда jellybit — связующий сервис qBittorrent ↔ Jellyfin.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/config"
|
||||
"git.vakhrushev.me/av/jellybit/internal/httpapi"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
_, _ = os.Stderr.WriteString("fatal: " + err.Error() + "\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
configPath := flag.String("config", "/data/config.toml", "путь к config.toml")
|
||||
flag.Parse()
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger := logging.New(cfg.Log.Level, cfg.Log.Format)
|
||||
logger.Info("starting jellybit", "config", *configPath)
|
||||
|
||||
st, err := store.Open(cfg.Storage.DBPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = st.Close() }()
|
||||
logger.Info("database ready", "path", cfg.Storage.DBPath)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.HTTP.Listen,
|
||||
Handler: httpapi.NewRouter(logger),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("http server listening", "addr", cfg.HTTP.Listen)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
logger.Info("shutdown signal received")
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("stopped")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Пример конфигурации jellybit. Реальный config.toml рендерится Ansible'ом
|
||||
# из переменных umbar и не коммитится (секреты — vars/secrets.yml).
|
||||
# Для локального запуска: db_path -> ./jellybit.db.
|
||||
|
||||
[qbittorrent]
|
||||
url = "http://qbit:8989" # по имени сервиса в общей docker-сети
|
||||
username = "admin"
|
||||
password = ""
|
||||
category = "jellybit"
|
||||
savepath = "/srv/media/downloads" # qBit кладёт загрузки сюда (задаём при добавлении)
|
||||
path_map = {} # фолбэк трансляции путей; обычно пуст
|
||||
|
||||
[paths]
|
||||
downloads = "/srv/media/downloads"
|
||||
movies = "/srv/media/movies"
|
||||
series = "/srv/media/series"
|
||||
|
||||
[storage]
|
||||
db_path = "/data/jellybit.db" # SQLite на persistent-томе
|
||||
|
||||
[llm]
|
||||
type = "openai-compat"
|
||||
# LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal.
|
||||
base_url = "http://host.docker.internal:1234/v1"
|
||||
api_key = ""
|
||||
model = "qwen2.5-32b-instruct"
|
||||
proxy = "" # опц. HTTP-прокси для удалённых эндпоинтов
|
||||
timeout = "120s"
|
||||
max_retries = 3
|
||||
|
||||
[metadata.tmdb]
|
||||
enabled = false # включается ключом; без матча авто не делаем
|
||||
api_key = ""
|
||||
proxy = ""
|
||||
timeout = "10s"
|
||||
|
||||
[metadata.tvdb]
|
||||
enabled = false
|
||||
api_key = ""
|
||||
proxy = ""
|
||||
timeout = "10s"
|
||||
|
||||
[worker]
|
||||
poll_interval = "5s"
|
||||
stuck_after = "1h"
|
||||
magnet_timeout = "30m"
|
||||
|
||||
[recognition]
|
||||
auto_confidence_threshold = 0.85
|
||||
|
||||
[telegram]
|
||||
enabled = false
|
||||
token = ""
|
||||
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
||||
|
||||
[http]
|
||||
listen = ":8080"
|
||||
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
|
||||
|
||||
[log]
|
||||
level = "info"
|
||||
format = "json"
|
||||
@@ -0,0 +1,31 @@
|
||||
module git.vakhrushev.me/av/jellybit
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
github.com/pelletier/go-toml/v2 v2.2.3
|
||||
github.com/pressly/goose/v3 v3.22.1
|
||||
modernc.org/sqlite v1.34.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sync v0.8.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.8.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.22.1 h1:2zICEfr1O3yTP9BRZMGPj7qFxQ+ik6yeo+z1LMuioLc=
|
||||
github.com/pressly/goose/v3 v3.22.1/go.mod h1:xtMpbstWyCpyH+0cxLTMCENWBG+0CSxvTsXhW95d5eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
|
||||
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
|
||||
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
|
||||
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,185 @@
|
||||
// Package config загружает конфигурацию jellybit из TOML-файла.
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
// Config — корневая конфигурация сервиса (см. config.example.toml).
|
||||
type Config struct {
|
||||
QBittorrent QBittorrent `toml:"qbittorrent"`
|
||||
Paths Paths `toml:"paths"`
|
||||
Storage Storage `toml:"storage"`
|
||||
LLM LLM `toml:"llm"`
|
||||
Metadata Metadata `toml:"metadata"`
|
||||
Worker Worker `toml:"worker"`
|
||||
Recognition Recognition `toml:"recognition"`
|
||||
Telegram Telegram `toml:"telegram"`
|
||||
HTTP HTTP `toml:"http"`
|
||||
Log Log `toml:"log"`
|
||||
}
|
||||
|
||||
// QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок.
|
||||
type QBittorrent struct {
|
||||
URL string `toml:"url"`
|
||||
Username string `toml:"username"`
|
||||
Password string `toml:"password"`
|
||||
Category string `toml:"category"`
|
||||
SavePath string `toml:"savepath"`
|
||||
PathMap map[string]string `toml:"path_map"`
|
||||
}
|
||||
|
||||
// Paths — хост-пути медиа-песочницы (см. docs/specs/architecture.md).
|
||||
type Paths struct {
|
||||
Downloads string `toml:"downloads"`
|
||||
Movies string `toml:"movies"`
|
||||
Series string `toml:"series"`
|
||||
}
|
||||
|
||||
// Storage — расположение БД.
|
||||
type Storage struct {
|
||||
DBPath string `toml:"db_path"`
|
||||
}
|
||||
|
||||
// LLM — провайдер распознавания (дискриминатор type).
|
||||
type LLM struct {
|
||||
Type string `toml:"type"`
|
||||
BaseURL string `toml:"base_url"`
|
||||
APIKey string `toml:"api_key"`
|
||||
Model string `toml:"model"`
|
||||
Proxy string `toml:"proxy"`
|
||||
Timeout Duration `toml:"timeout"`
|
||||
MaxRetries int `toml:"max_retries"`
|
||||
}
|
||||
|
||||
// Metadata — внешние базы метаданных (опциональны).
|
||||
type Metadata struct {
|
||||
TMDB MetadataProvider `toml:"tmdb"`
|
||||
TVDB MetadataProvider `toml:"tvdb"`
|
||||
}
|
||||
|
||||
// MetadataProvider — настройки одного провайдера метаданных.
|
||||
type MetadataProvider struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
APIKey string `toml:"api_key"`
|
||||
Proxy string `toml:"proxy"`
|
||||
Timeout Duration `toml:"timeout"`
|
||||
}
|
||||
|
||||
// Worker — параметры фонового цикла.
|
||||
type Worker struct {
|
||||
PollInterval Duration `toml:"poll_interval"`
|
||||
StuckAfter Duration `toml:"stuck_after"`
|
||||
MagnetTimeout Duration `toml:"magnet_timeout"`
|
||||
}
|
||||
|
||||
// Recognition — пороги распознавания.
|
||||
type Recognition struct {
|
||||
AutoConfidenceThreshold float64 `toml:"auto_confidence_threshold"`
|
||||
}
|
||||
|
||||
// Telegram — настройки бота (Ф5).
|
||||
type Telegram struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Token string `toml:"token"`
|
||||
AllowedUserIDs []int64 `toml:"allowed_user_ids"`
|
||||
}
|
||||
|
||||
// HTTP — параметры веб-сервера.
|
||||
type HTTP struct {
|
||||
Listen string `toml:"listen"`
|
||||
TrustedSubnets []string `toml:"trusted_subnets"`
|
||||
}
|
||||
|
||||
// Log — параметры логирования.
|
||||
type Log struct {
|
||||
Level string `toml:"level"`
|
||||
Format string `toml:"format"`
|
||||
}
|
||||
|
||||
// Duration — time.Duration, читаемый из TOML-строки вида "5s".
|
||||
type Duration time.Duration
|
||||
|
||||
// UnmarshalText разбирает строку длительности (encoding.TextUnmarshaler).
|
||||
func (d *Duration) UnmarshalText(text []byte) error {
|
||||
v, err := time.ParseDuration(string(text))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Std возвращает обычный time.Duration.
|
||||
func (d Duration) Std() time.Duration { return time.Duration(d) }
|
||||
|
||||
// Default возвращает конфиг с разумными умолчаниями; значения из файла
|
||||
// перекрывают их при загрузке.
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
QBittorrent: QBittorrent{
|
||||
URL: "http://qbit:8989",
|
||||
Username: "admin",
|
||||
Category: "jellybit",
|
||||
SavePath: "/srv/media/downloads",
|
||||
},
|
||||
Paths: Paths{
|
||||
Downloads: "/srv/media/downloads",
|
||||
Movies: "/srv/media/movies",
|
||||
Series: "/srv/media/series",
|
||||
},
|
||||
Storage: Storage{DBPath: "/data/jellybit.db"},
|
||||
LLM: LLM{
|
||||
Type: "openai-compat",
|
||||
Timeout: Duration(120 * time.Second),
|
||||
MaxRetries: 3,
|
||||
},
|
||||
Metadata: Metadata{
|
||||
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
|
||||
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
|
||||
},
|
||||
Worker: Worker{
|
||||
PollInterval: Duration(5 * time.Second),
|
||||
StuckAfter: Duration(time.Hour),
|
||||
MagnetTimeout: Duration(30 * time.Minute),
|
||||
},
|
||||
Recognition: Recognition{AutoConfidenceThreshold: 0.85},
|
||||
HTTP: HTTP{Listen: ":8080"},
|
||||
Log: Log{Level: "info", Format: "json"},
|
||||
}
|
||||
}
|
||||
|
||||
// Load читает и валидирует конфиг из path.
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := Default()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config %q: %w", path, err)
|
||||
}
|
||||
if err := toml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config %q: %w", path, err)
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid config %q: %w", path, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *Config) validate() error {
|
||||
if c.HTTP.Listen == "" {
|
||||
return errors.New("http.listen is empty")
|
||||
}
|
||||
if c.Storage.DBPath == "" {
|
||||
return errors.New("storage.db_path is empty")
|
||||
}
|
||||
if c.LLM.Type != "openai-compat" {
|
||||
return fmt.Errorf("unsupported llm.type %q (supported: openai-compat)", c.LLM.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Package httpapi предоставляет HTTP API и веб-UI (server-rendered + htmx).
|
||||
//
|
||||
// Сейчас — каркас: только /healthz. Эндпоинты приёма и ревью — в Ф1+.
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
// NewRouter собирает HTTP-обработчик сервиса.
|
||||
func NewRouter(logger *slog.Logger) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(requestLogger(logger))
|
||||
|
||||
r.Get("/healthz", handleHealthz)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func handleHealthz(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// requestLogger пишет структурированный лог по каждому запросу.
|
||||
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
start := time.Now()
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
logger.Info("http request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.Status(),
|
||||
"bytes", ww.BytesWritten(),
|
||||
"duration_ms", time.Since(start).Milliseconds(),
|
||||
"request_id", middleware.GetReqID(r.Context()),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package ingest — use-case приёма загрузки, общий для всех транспортов.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
|
||||
package ingest
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package layout — конвенции Jellyfin, санитизация путей, хардлинкер, undo.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф3 (см. docs/specs/jellyfin-layout.md).
|
||||
package layout
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package llm — провайдер LLM за интерфейсом (дискриминатор type).
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф2 (см. docs/specs/recognition.md).
|
||||
package llm
|
||||
@@ -0,0 +1,34 @@
|
||||
// Package logging собирает slog-логгер по настройкам из конфига.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// New возвращает slog-логгер с указанным уровнем и форматом ("json"|"text").
|
||||
func New(level, format string) *slog.Logger {
|
||||
opts := &slog.HandlerOptions{Level: parseLevel(level)}
|
||||
|
||||
var handler slog.Handler
|
||||
if strings.EqualFold(format, "text") {
|
||||
handler = slog.NewTextHandler(os.Stdout, opts)
|
||||
} else {
|
||||
handler = slog.NewJSONHandler(os.Stdout, opts)
|
||||
}
|
||||
return slog.New(handler)
|
||||
}
|
||||
|
||||
func parseLevel(level string) slog.Level {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB (опц.).
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф4 (см. docs/specs/architecture.md).
|
||||
package metadata
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package qbt — клиент qBittorrent WebUI API (сессия, добавление, опрос).
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
|
||||
package qbt
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package recognize — пред-парс имени, вызов LLM и модель уверенности.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф2 (см. docs/specs/recognition.md).
|
||||
package recognize
|
||||
@@ -0,0 +1,92 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE download (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_type TEXT NOT NULL, -- magnet | torrent | url
|
||||
source_ref TEXT NOT NULL,
|
||||
context TEXT NOT NULL DEFAULT '',
|
||||
infohash TEXT,
|
||||
idempotency_key TEXT,
|
||||
state TEXT NOT NULL,
|
||||
error_code TEXT,
|
||||
error_msg TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_download_idempotency_key
|
||||
ON download (idempotency_key)
|
||||
WHERE idempotency_key IS NOT NULL;
|
||||
|
||||
CREATE INDEX idx_download_state ON download (state);
|
||||
|
||||
CREATE TABLE recognition (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
download_id INTEGER NOT NULL REFERENCES download (id) ON DELETE CASCADE,
|
||||
attempt_no INTEGER NOT NULL DEFAULT 1,
|
||||
is_current INTEGER NOT NULL DEFAULT 1, -- 0/1
|
||||
media_type TEXT, -- movie | series
|
||||
title TEXT,
|
||||
original_title TEXT,
|
||||
year INTEGER,
|
||||
provider TEXT, -- tmdb | tvdb | none
|
||||
provider_id TEXT,
|
||||
confidence REAL,
|
||||
reasons TEXT NOT NULL DEFAULT '[]', -- JSON: причины «не авто»
|
||||
raw_llm TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_recognition_download ON recognition (download_id);
|
||||
|
||||
CREATE TABLE hint (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
download_id INTEGER NOT NULL REFERENCES download (id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_hint_download ON hint (download_id);
|
||||
|
||||
CREATE TABLE override (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
download_id INTEGER NOT NULL REFERENCES download (id) ON DELETE CASCADE,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE (download_id, field)
|
||||
);
|
||||
|
||||
CREATE TABLE metadata_candidate (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recognition_id INTEGER NOT NULL REFERENCES recognition (id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
title TEXT,
|
||||
year INTEGER,
|
||||
chosen INTEGER NOT NULL DEFAULT 0, -- 0/1
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_candidate_recognition ON metadata_candidate (recognition_id);
|
||||
|
||||
CREATE TABLE file_link (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
download_id INTEGER NOT NULL REFERENCES download (id) ON DELETE CASCADE,
|
||||
apply_batch_id TEXT NOT NULL,
|
||||
src_path TEXT NOT NULL,
|
||||
dst_path TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- video | subtitle | ...
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_file_link_download ON file_link (download_id);
|
||||
CREATE INDEX idx_file_link_batch ON file_link (apply_batch_id);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE file_link;
|
||||
DROP TABLE metadata_candidate;
|
||||
DROP TABLE override;
|
||||
DROP TABLE hint;
|
||||
DROP TABLE recognition;
|
||||
DROP TABLE download;
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package store отвечает за подключение к SQLite и миграции схемы.
|
||||
package store
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/pressly/goose/v3"
|
||||
_ "modernc.org/sqlite" // драйвер database/sql, имя "sqlite"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Store оборачивает подключение к базе.
|
||||
type Store struct {
|
||||
DB *sqlx.DB
|
||||
}
|
||||
|
||||
// Open открывает (создавая при необходимости) базу по пути dbPath,
|
||||
// прогоняет миграции и возвращает готовый Store.
|
||||
func Open(dbPath string) (*Store, error) {
|
||||
if dir := filepath.Dir(dbPath); dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create db dir %q: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf(
|
||||
"file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)",
|
||||
dbPath,
|
||||
)
|
||||
db, err := sqlx.Connect("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite %q: %w", dbPath, err)
|
||||
}
|
||||
|
||||
if err := migrate(db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Store{DB: db}, nil
|
||||
}
|
||||
|
||||
func migrate(db *sqlx.DB) error {
|
||||
goose.SetBaseFS(migrationsFS)
|
||||
goose.SetLogger(goose.NopLogger()) // не ломать JSON-логи; ошибки идут через return
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return fmt.Errorf("set goose dialect: %w", err)
|
||||
}
|
||||
if err := goose.Up(db.DB, "migrations"); err != nil {
|
||||
return fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close закрывает подключение к базе.
|
||||
func (s *Store) Close() error {
|
||||
return s.DB.Close()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package tgbot — Telegram-адаптер, парсер сообщений бота и исходящие пинги.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф5 (см. docs/specs/review-ux.md).
|
||||
package tgbot
|
||||
@@ -0,0 +1,4 @@
|
||||
// Package worker — владелец машины состояний и поллинга qBittorrent.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
|
||||
package worker
|
||||
@@ -0,0 +1,23 @@
|
||||
# https://lefthook.dev/configuration/
|
||||
|
||||
pre-commit:
|
||||
jobs:
|
||||
- name: "gofmt"
|
||||
glob: "*.go"
|
||||
run: "gofmt -w {staged_files}"
|
||||
stage_fixed: true
|
||||
|
||||
- name: "go vet"
|
||||
glob: "*.go"
|
||||
run: "go vet ./..."
|
||||
|
||||
- name: "golangci-lint"
|
||||
glob: "*.go"
|
||||
run: "golangci-lint run"
|
||||
|
||||
- name: "go test"
|
||||
glob: "*.go"
|
||||
run: "go test ./..."
|
||||
|
||||
- name: "gitleaks"
|
||||
run: "gitleaks git --staged"
|
||||
Reference in New Issue
Block a user