Добавил каркас приложения

This commit is contained in:
2026-06-14 11:17:01 +03:00
parent a48f39d7f0
commit ed4b4fb15e
25 changed files with 824 additions and 8 deletions
+3
View File
@@ -0,0 +1,3 @@
# Канонический деплой кладёт в build-контекст только готовый бинарь.
*
!jellybit
+13
View File
@@ -0,0 +1,13 @@
# Сборка
/jellybit
/dist/
# Реальный конфиг (секреты) и локальная БД
/config.toml
*.db
*.db-wal
*.db-shm
# IDE
/.idea/
/.vscode/
+13
View File
@@ -0,0 +1,13 @@
# Минимальный конфиг golangci-lint (формат v1).
# При golangci-lint v2 может потребоваться адаптация схемы.
run:
timeout: 5m
linters:
enable:
- errcheck
- govet
- ineffassign
- staticcheck
- unused
- misspell
+8 -4
View File
@@ -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` Module path — `git.vakhrushev.me/av/jellybit`. Go 1.23, `CGO_ENABLED=0`.
- тесты: `go test ./...` Стек: `chi`, `sqlx` + `modernc.org/sqlite`, `goose` (миграции),
- линт: `golangci-lint run` `pelletier/go-toml/v2`, `log/slog`.
## Конвенции кода ## Конвенции кода
+11
View File
@@ -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"]
+28
View File
@@ -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)
+17 -4
View File
@@ -32,8 +32,9 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
## Статус ## Статус
Ранняя разработка. Сейчас зафиксированы архитектура и решения, кода ещё Ранняя разработка. Готов каркас (Ф0): загрузка TOML-конфига, SQLite +
нет. См. [дорожную карту](docs/drafts/roadmap.md). миграции, slog-логи, HTTP-сервер с `/healthz`. Дальше — приём загрузок и
трекинг (Ф1). См. [дорожную карту](docs/drafts/roadmap.md).
## Документация ## Документация
@@ -44,10 +45,22 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
## Стек ## Стек
Go (один статический бинарь), SQLite, конфигурация — TOML, логи — Go (один статический бинарь), SQLite (`modernc.org/sqlite` + `sqlx`,
структурированный JSON. Подробнее — в миграции `goose`), HTTP — `chi` + `html/template` + htmx, конфигурация —
TOML, логи — структурированный JSON (`slog`). Подробнее — в
[architecture.md](docs/specs/architecture.md). [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 Сборка здесь → готовый бинарь копируется на медиа-сервер umbar
+77
View File
@@ -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
}
+62
View File
@@ -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"
+31
View File
@@ -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
)
+79
View File
@@ -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=
+185
View File
@@ -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
}
+52
View File
@@ -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()),
)
})
}
}
+4
View File
@@ -0,0 +1,4 @@
// Package ingest — use-case приёма загрузки, общий для всех транспортов.
//
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
package ingest
+4
View File
@@ -0,0 +1,4 @@
// Package layout — конвенции Jellyfin, санитизация путей, хардлинкер, undo.
//
// Заглушка: реализация в фазе Ф3 (см. docs/specs/jellyfin-layout.md).
package layout
+4
View File
@@ -0,0 +1,4 @@
// Package llm — провайдер LLM за интерфейсом (дискриминатор type).
//
// Заглушка: реализация в фазе Ф2 (см. docs/specs/recognition.md).
package llm
+34
View File
@@ -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
}
}
+4
View File
@@ -0,0 +1,4 @@
// Package metadata — интерфейс баз метаданных и клиенты TMDB/TVDB (опц.).
//
// Заглушка: реализация в фазе Ф4 (см. docs/specs/architecture.md).
package metadata
+4
View File
@@ -0,0 +1,4 @@
// Package qbt — клиент qBittorrent WebUI API (сессия, добавление, опрос).
//
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
package qbt
+4
View File
@@ -0,0 +1,4 @@
// Package recognize — пред-парс имени, вызов LLM и модель уверенности.
//
// Заглушка: реализация в фазе Ф2 (см. docs/specs/recognition.md).
package recognize
+92
View File
@@ -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;
+64
View File
@@ -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()
}
+4
View File
@@ -0,0 +1,4 @@
// Package tgbot — Telegram-адаптер, парсер сообщений бота и исходящие пинги.
//
// Заглушка: реализация в фазе Ф5 (см. docs/specs/review-ux.md).
package tgbot
+4
View File
@@ -0,0 +1,4 @@
// Package worker — владелец машины состояний и поллинга qBittorrent.
//
// Заглушка: реализация в фазе Ф1 (см. docs/specs/architecture.md).
package worker
+23
View File
@@ -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"