diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0789bc2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +# Канонический деплой кладёт в build-контекст только готовый бинарь. +* +!jellybit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42e84fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Сборка +/jellybit +/dist/ + +# Реальный конфиг (секреты) и локальная БД +/config.toml +*.db +*.db-wal +*.db-shm + +# IDE +/.idea/ +/.vscode/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..fb24e1a --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,13 @@ +# Минимальный конфиг golangci-lint (формат v1). +# При golangci-lint v2 может потребоваться адаптация схемы. +run: + timeout: 5m + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + - unused + - misspell diff --git a/CLAUDE.md b/CLAUDE.md index 618f1b8..d40b5be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. ## Конвенции кода diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..94c0cdf --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b57b074 --- /dev/null +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index c58907a..756897c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/jellybit/main.go b/cmd/jellybit/main.go new file mode 100644 index 0000000..69aa963 --- /dev/null +++ b/cmd/jellybit/main.go @@ -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 +} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..8223fbf --- /dev/null +++ b/config.example.toml @@ -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" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..154a3d1 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e594673 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..30b2c4b --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go new file mode 100644 index 0000000..34a795b --- /dev/null +++ b/internal/httpapi/httpapi.go @@ -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()), + ) + }) + } +} diff --git a/internal/ingest/doc.go b/internal/ingest/doc.go new file mode 100644 index 0000000..e935732 --- /dev/null +++ b/internal/ingest/doc.go @@ -0,0 +1,4 @@ +// Package ingest — use-case приёма загрузки, общий для всех транспортов. +// +// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md). +package ingest diff --git a/internal/layout/doc.go b/internal/layout/doc.go new file mode 100644 index 0000000..d444496 --- /dev/null +++ b/internal/layout/doc.go @@ -0,0 +1,4 @@ +// Package layout — конвенции Jellyfin, санитизация путей, хардлинкер, undo. +// +// Заглушка: реализация в фазе Ф3 (см. docs/specs/jellyfin-layout.md). +package layout diff --git a/internal/llm/doc.go b/internal/llm/doc.go new file mode 100644 index 0000000..cb3c935 --- /dev/null +++ b/internal/llm/doc.go @@ -0,0 +1,4 @@ +// Package llm — провайдер LLM за интерфейсом (дискриминатор type). +// +// Заглушка: реализация в фазе Ф2 (см. docs/specs/recognition.md). +package llm diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..7ecfaa0 --- /dev/null +++ b/internal/logging/logging.go @@ -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 + } +} diff --git a/internal/metadata/doc.go b/internal/metadata/doc.go new file mode 100644 index 0000000..9c1c39a --- /dev/null +++ b/internal/metadata/doc.go @@ -0,0 +1,4 @@ +// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB (опц.). +// +// Заглушка: реализация в фазе Ф4 (см. docs/specs/architecture.md). +package metadata diff --git a/internal/qbt/doc.go b/internal/qbt/doc.go new file mode 100644 index 0000000..6916f06 --- /dev/null +++ b/internal/qbt/doc.go @@ -0,0 +1,4 @@ +// Package qbt — клиент qBittorrent WebUI API (сессия, добавление, опрос). +// +// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md). +package qbt diff --git a/internal/recognize/doc.go b/internal/recognize/doc.go new file mode 100644 index 0000000..e16ae58 --- /dev/null +++ b/internal/recognize/doc.go @@ -0,0 +1,4 @@ +// Package recognize — пред-парс имени, вызов LLM и модель уверенности. +// +// Заглушка: реализация в фазе Ф2 (см. docs/specs/recognition.md). +package recognize diff --git a/internal/store/migrations/0001_init.sql b/internal/store/migrations/0001_init.sql new file mode 100644 index 0000000..23192ee --- /dev/null +++ b/internal/store/migrations/0001_init.sql @@ -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; diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..e837494 --- /dev/null +++ b/internal/store/store.go @@ -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() +} diff --git a/internal/tgbot/doc.go b/internal/tgbot/doc.go new file mode 100644 index 0000000..abb223d --- /dev/null +++ b/internal/tgbot/doc.go @@ -0,0 +1,4 @@ +// Package tgbot — Telegram-адаптер, парсер сообщений бота и исходящие пинги. +// +// Заглушка: реализация в фазе Ф5 (см. docs/specs/review-ux.md). +package tgbot diff --git a/internal/worker/doc.go b/internal/worker/doc.go new file mode 100644 index 0000000..7938831 --- /dev/null +++ b/internal/worker/doc.go @@ -0,0 +1,4 @@ +// Package worker — владелец машины состояний и поллинга qBittorrent. +// +// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md). +package worker diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..840514a --- /dev/null +++ b/lefthook.yml @@ -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"