Compare commits

..

20 Commits

Author SHA1 Message Date
av 1e8000429a Todo про рассинхрон данных 2026-06-15 12:03:42 +03:00
av 70b3c7ae14 Добавил todo с доработками 2026-06-15 11:50:49 +03:00
av d727966f29 ADR про распознавание контента 2026-06-15 11:38:18 +03:00
av d149cb7481 Синхронизация документации и кода 2026-06-15 11:27:03 +03:00
av b1f97c105a Разделил хранимые данные по слоям: config, data, cache 2026-06-15 10:58:05 +03:00
av 157f626c2e Сделал для http запросов уровень логирования DEBUG 2026-06-15 08:55:29 +03:00
av e297f0fb84 Фикс повторного распознавания 2026-06-15 07:57:22 +03:00
av 16a82572e7 Добавил ручную перепривязку 2026-06-15 07:42:50 +03:00
av 093211c9c7 Добавил обновление библиотеки jellyfin после добавления медиа 2026-06-15 07:33:21 +03:00
av fff0960915 Фикс не найденного торрента при ревью 2026-06-14 20:54:47 +03:00
av 0e69a86a89 Обновил readme 2026-06-14 19:48:39 +03:00
av 81ed58ecff Добавил логи 2026-06-14 19:37:09 +03:00
av d4bf8a8cad Фикс отображения путей 2026-06-14 19:09:50 +03:00
av 5fb2f4df43 Добавил прокси для Телеграм 2026-06-14 18:56:04 +03:00
av 2dbbb1b706 Добавил распознавание файлов для проверки 2026-06-14 17:21:15 +03:00
av 4e077d878e Добавил "усыновление" существующих торрентов при добавлении тега или
категории
2026-06-14 17:06:59 +03:00
av 7f7f5f69d4 Добавил выбор из кандидатов, если LLM не уверена в раскладке 2026-06-14 16:43:50 +03:00
av 4af3ad2dde Добавил сборку 2026-06-14 16:10:21 +03:00
av 08b707f602 Добавил бот для Telegram 2026-06-14 15:55:33 +03:00
av 7419bcb125 Добавил еще провайдер TVMaze 2026-06-14 15:29:04 +03:00
72 changed files with 4514 additions and 400 deletions
+10 -1
View File
@@ -3,9 +3,18 @@
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit # CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit
# distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь # distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь
# задаётся в compose (user: "1000:1000"). # задаётся в compose (user: "1000:1000").
#
# Тома (см. compose): /config (ro, рендерится плейбуком — восстановимо при
# деплое) + /data (SQLite, бекапить-и-не-терять).
FROM gcr.io/distroless/static-debian12 FROM gcr.io/distroless/static-debian12
COPY jellybit /usr/local/bin/jellybit COPY jellybit /usr/local/bin/jellybit
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/data/config.toml"]
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
# /config/config.toml — дефолтный путь). compose может переопределить параметры.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/jellybit", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/config/config.toml"]
+41 -13
View File
@@ -1,10 +1,10 @@
# Jellybit # Jellybit
Jellybit — связующий сервис между qBittorrent и Jellyfin. Принимает Jellybit — связующий сервис между qBittorrent и Jellyfin. Принимает
торрент (magnet, `.torrent` или ссылку) вместе с текстовым контекстом, magnet-ссылку вместе с текстовым контекстом, ставит загрузку в
ставит загрузку в qBittorrent, дожидается её завершения, распознаёт qBittorrent, дожидается её завершения, распознаёт содержимое (фильм или
содержимое (фильм или сериал, сезоны и серии) и раскладывает готовые сериал, сезоны и серии) и раскладывает готовые файлы по конвенциям
файлы по конвенциям библиотеки Jellyfin. библиотеки Jellyfin.
Полный замысел и причины — в [BRIEF.md](BRIEF.md). Полный замысел и причины — в [BRIEF.md](BRIEF.md).
@@ -18,25 +18,34 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
## Как работает ## Как работает
1. Точка входа принимает torrent/magnet + контекст (HTTP API, веб-UI 1. Точка входа принимает magnet + контекст (HTTP API, веб-UI,
или Telegram-бот). Telegram-бот или CLI).
2. Загрузка ставится в qBittorrent в выделенную категорию. 2. Загрузка ставится в qBittorrent в выделенную категорию.
3. Сервис отслеживает завершение загрузки. 3. Сервис отслеживает завершение загрузки.
4. По именам файлов, контексту и (опц.) базам метаданных определяется 4. По именам файлов, контексту и (опц.) базам метаданных определяется
фильм/сериал и нужная раскладка. фильм/сериал и нужная раскладка.
5. Файлы **хардлинкаются** в библиотеку Jellyfin — источник остаётся в 5. Файлы **хардлинкаются** в библиотеку Jellyfin — источник остаётся в
раздаче, место на диске не дублируется. раздаче, место на диске не дублируется.
6. После раскладки сервис (опц.) просит Jellyfin пересканировать
медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
При высокой уверенности раскладка выполняется автоматически, иначе — При высокой уверенности раскладка выполняется автоматически, иначе —
уходит на подтверждение человеку. уходит на подтверждение человеку.
Доступ к внешним сервисам (LLM, базы метаданных, Telegram) при
необходимости идёт через HTTP-прокси — задаётся полем `proxy` в
соответствующих секциях конфигурации.
## Статус ## Статус
Ранняя разработка. Готовы каркас (Ф0) и приём + трекинг (Ф1): добавление Рабочий прототип с полным сквозным путём: приём magnet → загрузка в
magnet в qBittorrent, идемпотентность по infohash, поллинг завершения и qBittorrent → распознавание (LLM + опционально базы метаданных
машина состояний (`downloading → completed`, плюс stuck/failed); наружу — TMDB/TVDB/TVMaze) → раскладка в библиотеку хардлинками, автоматически при
REST API, веб-UI и `jellybit add`. Источники кроме magnet (.torrent/url) и уверенном результате либо через подтверждение человеком. Транспорты приёма:
распознавание (Ф2) — дальше. См. [дорожную карту](docs/drafts/roadmap.md). REST API, веб-UI, Telegram-бот и CLI (`jellybit add`).
Из источников пока поддержан magnet; `.torrent` и обычные ссылки — в планах.
См. [дорожную карту](docs/drafts/roadmap.md).
## Документация ## Документация
@@ -67,7 +76,26 @@ task build # статический бинарь (linu
task image # docker-образ из готового бинаря task image # docker-образ из готового бинаря
``` ```
Отладка распознавания на реальной раздаче (только чтение, без раскладки):
```bash
jellybit recognize <infohash> --dry-run [--context "..."] --config ./config.toml
```
Берёт торрент из qBittorrent по infohash, прогоняет распознавание (LLM +
метабазы) и печатает план: тип/название/год, матч в базе, решение авто/review
и превью целевых путей — то, что создалось бы при Apply.
## Доставка ## Доставка
Сборка здесь → готовый бинарь копируется на медиа-сервер umbar Рассчитан на домашний медиа-сервер. Артефакты репозитория — статический
(`/home/av/projects/private/umbar`). Деплой-обвязка живёт в umbar. бинарь (`task build`) и `Dockerfile` (упаковка в `distroless/static`). Образ
собирается **на сервере** из доставленного бинаря, поэтому Go-тулчейн на
сервере не нужен. В distroless нет shell/curl, поэтому HEALTHCHECK зовёт сам
бинарь: `jellybit healthcheck` (GET `/healthz` по порту из конфига, exit 0/1).
Контейнер: `user 1000:1000`, порт `8080` на хост, mount `/srv/media` (единая
песочница для хардлинков) + том `/config` (ro, `config.toml`, восстановим при
деплое) + data-том `/data` (SQLite, бекапить); к qBittorrent — по сети Docker.
Конкретная деплой-обвязка (плейбук, секреты) держится в отдельном приватном
репозитории и в комплект не входит.
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"context"
"flag"
"fmt"
"net"
"net/http"
"time"
"git.vakhrushev.me/av/jellybit/internal/config"
)
// runHealthcheck дёргает /healthz локального сервиса и завершается с кодом 0
// при 200, иначе ненулевым. Нужен для HEALTHCHECK в distroless-образе, где
// нет shell/curl: docker зовёт сам бинарь.
func runHealthcheck(args []string) error {
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
if err := fs.Parse(args); err != nil {
return err
}
cfg, err := config.Load(*configPath)
if err != nil {
return err
}
// listen вида ":8080" или "127.0.0.1:8080" → стучимся на localhost:<port>.
_, port, err := net.SplitHostPort(cfg.HTTP.Listen)
if err != nil {
return fmt.Errorf("parse http.listen %q: %w", cfg.HTTP.Listen, err)
}
url := "http://127.0.0.1:" + port + "/healthz"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("healthcheck request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("healthcheck: status %d", resp.StatusCode)
}
return nil
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"testing"
)
// serveHealthz поднимает http-сервер на свободном порту, отдавая указанный
// статус на /healthz. Возвращает порт и стоп-функцию.
func serveHealthz(t *testing.T, status int) int {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
})
srv := &http.Server{Handler: mux}
go func() { _ = srv.Serve(ln) }()
t.Cleanup(func() { _ = srv.Close() })
return ln.Addr().(*net.TCPAddr).Port
}
func writeConfig(t *testing.T, port int) string {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
content := "[http]\nlisten = \"127.0.0.1:" + strconv.Itoa(port) + "\"\n"
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestHealthcheck_OK(t *testing.T) {
port := serveHealthz(t, http.StatusOK)
if err := runHealthcheck([]string{"--config", writeConfig(t, port)}); err != nil {
t.Errorf("healthcheck: %v", err)
}
}
func TestHealthcheck_BadStatus(t *testing.T) {
port := serveHealthz(t, http.StatusServiceUnavailable)
if err := runHealthcheck([]string{"--config", writeConfig(t, port)}); err == nil {
t.Error("ожидалась ошибка при 503")
}
}
func TestHealthcheck_NoServer(t *testing.T) {
// Порт, на котором никто не слушает (берём свободный и закрываем).
ln, _ := net.Listen("tcp", "127.0.0.1:0")
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
if err := runHealthcheck([]string{"--config", writeConfig(t, port)}); err == nil {
t.Error("ожидалась ошибка без сервера")
}
}
+6
View File
@@ -4,6 +4,8 @@
// //
// jellybit [serve] --config <path> запустить сервис (по умолчанию) // jellybit [serve] --config <path> запустить сервис (по умолчанию)
// jellybit add <magnet> [--context] добавить загрузку через REST API сервиса // jellybit add <magnet> [--context] добавить загрузку через REST API сервиса
// jellybit recognize <infohash> --dry-run показать план распознавания (без записи)
// jellybit healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
package main package main
import ( import (
@@ -27,6 +29,10 @@ func main() {
err = runServe(args) err = runServe(args)
case "add": case "add":
err = runAdd(args) err = runAdd(args)
case "recognize":
err = runRecognize(args)
case "healthcheck":
err = runHealthcheck(args)
default: default:
_, _ = os.Stderr.WriteString("unknown command: " + cmd + "\n") _, _ = os.Stderr.WriteString("unknown command: " + cmd + "\n")
os.Exit(2) os.Exit(2)
+202
View File
@@ -0,0 +1,202 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"strings"
"time"
"git.vakhrushev.me/av/jellybit/internal/config"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/llm"
"git.vakhrushev.me/av/jellybit/internal/metadata"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// runRecognize — диагностика распознавания: берёт торрент из qBittorrent по
// infohash, прогоняет конвейер (LLM + метабазы) и печатает план раскладки.
// Только чтение: ни записи в БД, ни хардлинков.
func runRecognize(args []string) error {
fs := flag.NewFlagSet("recognize", flag.ContinueOnError)
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)")
contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания")
if err := fs.Parse(args); err != nil {
return err
}
infohash := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
// flag останавливается на первом позиционном — допарсим флаги, стоящие
// после <infohash> (напр. `recognize <infohash> --dry-run`).
if fs.NArg() > 1 {
if err := fs.Parse(fs.Args()[1:]); err != nil {
return err
}
}
if infohash == "" {
return fmt.Errorf("usage: jellybit recognize <infohash> [--dry-run] [--context ...]")
}
if !*dryRun {
return fmt.Errorf("recognize runs only in --dry-run mode; layout is applied via review")
}
cfg, err := config.Load(*configPath)
if err != nil {
return err
}
if cfg.LLM.Type == "" || cfg.LLM.BaseURL == "" {
return fmt.Errorf("[llm] is not configured in config — recognition is unavailable")
}
// Внутренние логи (ретраи/ошибки провайдеров) — в stderr, чтобы не мешать
// плану в stdout.
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
// qBittorrent: ищем торрент и его файлы.
qb, err := qbt.New(qbt.Config{
URL: cfg.QBittorrent.URL,
Username: cfg.QBittorrent.Username,
Password: cfg.QBittorrent.Password,
}, logger)
if err != nil {
return err
}
torrents, err := qb.Torrents(ctx, "")
if err != nil {
return fmt.Errorf("qbittorrent: %w", err)
}
t, ok := findTorrent(torrents, infohash)
if !ok {
return fmt.Errorf("torrent with infohash %s not found in qBittorrent", infohash)
}
files, err := qb.Files(ctx, t.Hash)
if err != nil {
return fmt.Errorf("qbittorrent files: %w", err)
}
// Провайдеры метаданных + LLM + распознаватель.
providers, err := metadataProviders(cfg, logger)
if err != nil {
return err
}
provider, err := llm.New(llm.Config{
Type: cfg.LLM.Type, BaseURL: cfg.LLM.BaseURL, APIKey: cfg.LLM.APIKey,
Model: cfg.LLM.Model, Proxy: cfg.LLM.Proxy, Timeout: cfg.LLM.Timeout.Std(),
}, logger)
if err != nil {
return err
}
rec := recognize.New(provider, providers, recognize.Config{
MaxRetries: cfg.LLM.MaxRetries,
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
}, logger)
in := recognize.Input{Name: t.Name, Context: *contextStr}
for _, f := range files {
in.Files = append(in.Files, recognize.File{Path: f.Name, Size: f.Size})
}
start := time.Now()
res, err := rec.Recognize(ctx, in)
if err != nil {
return fmt.Errorf("recognize: %w", err)
}
// Раскладчик для превью (BuildLinks ничего не пишет; логгер не нужен).
lay, err := layout.New(layout.Config{MoviesDir: cfg.Paths.Movies, SeriesDir: cfg.Paths.Series}, nil)
if err != nil {
return err
}
printDryRun(t, files, res, lay, providerNames(providers), time.Since(start))
return nil
}
func findTorrent(torrents []qbt.Torrent, infohash string) (qbt.Torrent, bool) {
for _, t := range torrents {
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
if h != "" && strings.EqualFold(h, infohash) {
return t, true
}
}
}
return qbt.Torrent{}, false
}
func printDryRun(t qbt.Torrent, files []qbt.File, res recognize.Result, lay *layout.Layouter, providers []string, took time.Duration) {
p := res.Plan
fmt.Printf("════ Торрент ════\n")
fmt.Printf("name : %s\n", t.Name)
fmt.Printf("infohash : %s\n", t.Hash)
fmt.Printf("save_path : %s\n", t.SavePath)
fmt.Printf("файлов : %d state: %s\n\n", len(files), t.State)
fmt.Printf("════ Распознавание ════\n")
fmt.Printf("провайдеры базы: %v\n", providers)
fmt.Printf("заняло : %s, попыток LLM: %d\n", took.Truncate(time.Millisecond), res.Attempts)
fmt.Printf("тип : %s\n", p.Type)
fmt.Printf("название : %s", p.Title)
if p.OriginalTitle != "" {
fmt.Printf(" (ориг: %s)", p.OriginalTitle)
}
fmt.Printf("\nгод : %d\n", p.Year)
fmt.Printf("self-confidence: %.2f\n", p.Confidence)
if p.Notes != "" {
fmt.Printf("notes : %s\n", p.Notes)
}
fmt.Printf("\n──── Матч в базе ────\n")
if m := res.Match; m != nil {
fmt.Printf("provider=%s id=%s title=%q year=%d\n", m.Provider, m.ProviderID, m.Title, m.Year)
if len(m.SeasonEpisodeCounts) > 0 {
fmt.Printf("серий по сезонам в базе: %v\n", m.SeasonEpisodeCounts)
}
} else {
fmt.Printf("единичного сильного матча нет\n")
}
if len(res.Candidates) > 0 {
fmt.Printf("кандидаты для ручного выбора (%d):\n", len(res.Candidates))
for _, c := range res.Candidates {
tagP, tagID := recognize.CandidateTag(c)
fmt.Printf(" · %s/%s %q (%d) [тег: %s-%s]\n", c.Provider, c.ID, c.Title, c.Year, tagP, tagID)
}
}
fmt.Printf("\n──── Решение ────\n")
if res.Decision.Auto {
fmt.Printf("АВТО-раскладка (review не нужен)\n")
} else {
fmt.Printf("REVIEW — причины:\n")
for _, reason := range res.Decision.Reasons {
fmt.Printf(" · %s\n", reason)
}
}
fmt.Printf("\n──── Превью раскладки (хардлинки НЕ создаются) ────\n")
tag := ""
if res.Match != nil {
tag = worker.ProviderTag(res.Match.Provider, res.Match.ProviderID)
}
links, err := lay.BuildLinks(worker.ToLayoutPlan(p, t.SavePath, tag))
if err != nil {
fmt.Printf("план не построился: %v\n", err)
return
}
for _, l := range links {
fmt.Printf(" [%s] %s\n", l.Kind, l.Dst)
}
fmt.Printf("\nИтого ссылок: %d (это создалось бы при Apply)\n", len(links))
}
func providerNames(providers []metadata.Provider) []string {
out := make([]string, len(providers))
for i, p := range providers {
out[i] = p.Name()
}
return out
}
+21
View File
@@ -0,0 +1,21 @@
package main
import (
"strings"
"testing"
)
func TestRecognize_RequiresInfohash(t *testing.T) {
if err := runRecognize(nil); err == nil || !strings.Contains(err.Error(), "usage") {
t.Errorf("без infohash ожидалась usage-ошибка, got %v", err)
}
}
func TestRecognize_DryRunOnly(t *testing.T) {
// Флаг после позиционного должен разобраться (допарсинг), а --dry-run=false
// — отклониться до обращения к конфигу/сети.
err := runRecognize([]string{"abc123", "--dry-run=false"})
if err == nil || !strings.Contains(err.Error(), "dry-run") {
t.Errorf("ожидалась ошибка про dry-run, got %v", err)
}
}
+94 -10
View File
@@ -5,14 +5,19 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"net/url"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"git.vakhrushev.me/av/jellybit/internal/config" "git.vakhrushev.me/av/jellybit/internal/config"
"git.vakhrushev.me/av/jellybit/internal/httpapi" "git.vakhrushev.me/av/jellybit/internal/httpapi"
"git.vakhrushev.me/av/jellybit/internal/ingest" "git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/jellyfin"
"git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/llm" "git.vakhrushev.me/av/jellybit/internal/llm"
"git.vakhrushev.me/av/jellybit/internal/logging" "git.vakhrushev.me/av/jellybit/internal/logging"
@@ -20,6 +25,7 @@ import (
"git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/tgbot"
"git.vakhrushev.me/av/jellybit/internal/worker" "git.vakhrushev.me/av/jellybit/internal/worker"
) )
@@ -27,7 +33,7 @@ import (
// воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM. // воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM.
func runServe(args []string) error { func runServe(args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError) fs := flag.NewFlagSet("serve", flag.ContinueOnError)
configPath := fs.String("config", "/data/config.toml", "путь к config.toml") configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
@@ -51,7 +57,7 @@ func runServe(args []string) error {
URL: cfg.QBittorrent.URL, URL: cfg.QBittorrent.URL,
Username: cfg.QBittorrent.Username, Username: cfg.QBittorrent.Username,
Password: cfg.QBittorrent.Password, Password: cfg.QBittorrent.Password,
}) }, logger)
if err != nil { if err != nil {
return err return err
} }
@@ -62,7 +68,7 @@ func runServe(args []string) error {
}, logger) }, logger)
// Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review. // Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review.
providers, err := metadataProviders(cfg) providers, err := metadataProviders(cfg, logger)
if err != nil { if err != nil {
return err return err
} }
@@ -81,7 +87,7 @@ func runServe(args []string) error {
Model: cfg.LLM.Model, Model: cfg.LLM.Model,
Proxy: cfg.LLM.Proxy, Proxy: cfg.LLM.Proxy,
Timeout: cfg.LLM.Timeout.Std(), Timeout: cfg.LLM.Timeout.Std(),
}) }, logger)
if perr != nil { if perr != nil {
return fmt.Errorf("llm provider: %w", perr) return fmt.Errorf("llm provider: %w", perr)
} }
@@ -97,19 +103,40 @@ func runServe(args []string) error {
layouter, err := layout.New(layout.Config{ layouter, err := layout.New(layout.Config{
MoviesDir: cfg.Paths.Movies, MoviesDir: cfg.Paths.Movies,
SeriesDir: cfg.Paths.Series, SeriesDir: cfg.Paths.Series,
}) }, logger)
if err != nil { if err != nil {
return fmt.Errorf("layouter: %w", err) return fmt.Errorf("layouter: %w", err)
} }
wrk := worker.New(st, qb, recognizer, layouter, worker.Config{ wrk := worker.New(st, qb, recognizer, layouter, worker.Config{
Category: cfg.QBittorrent.Category, Category: cfg.QBittorrent.Category,
Tag: cfg.QBittorrent.Tag,
SavePath: cfg.QBittorrent.SavePath, SavePath: cfg.QBittorrent.SavePath,
PathMap: cfg.QBittorrent.PathMap,
PollInterval: cfg.Worker.PollInterval.Std(), PollInterval: cfg.Worker.PollInterval.Std(),
StuckAfter: cfg.Worker.StuckAfter.Std(), StuckAfter: cfg.Worker.StuckAfter.Std(),
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(), MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
}, logger) }, logger)
// Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin
// не валит сервис — скан просто не сработает (залогируется в воркере).
if cfg.Jellyfin.Enabled {
if cfg.Jellyfin.URL == "" || cfg.Jellyfin.APIKey == "" {
return fmt.Errorf("jellyfin enabled, but url or api_key is empty")
}
jf, jerr := jellyfin.New(jellyfin.Config{
URL: cfg.Jellyfin.URL,
APIKey: cfg.Jellyfin.APIKey,
Proxy: cfg.Jellyfin.Proxy,
Timeout: cfg.Jellyfin.Timeout.Std(),
}, logger)
if jerr != nil {
return fmt.Errorf("jellyfin client: %w", jerr)
}
wrk.SetScanner(jf)
logger.Info("jellyfin rescan enabled", "url", cfg.Jellyfin.URL)
}
router, err := httpapi.NewRouter(httpapi.Deps{ router, err := httpapi.NewRouter(httpapi.Deps{
Logger: logger, Logger: logger,
Ingestor: ingestor, Ingestor: ingestor,
@@ -124,6 +151,32 @@ func runServe(args []string) error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
// Ф5: Telegram-транспорт + пинги. Доступ — по allowed_user_ids
// (пусто = запрет всем, fail-closed). Недоступность Telegram на старте не
// валит сервис — бот просто отключается.
if cfg.Telegram.Enabled {
if cfg.Telegram.Token == "" {
return fmt.Errorf("telegram enabled, but token is empty")
}
tgClient, perr := telegramHTTPClient(cfg.Telegram.Proxy)
if perr != nil {
return perr
}
api, terr := tgbotapi.NewBotAPIWithClient(cfg.Telegram.Token, tgbotapi.APIEndpoint, tgClient)
if terr != nil {
logger.Error("telegram bot disabled: cannot connect", "err", terr)
} else {
bot := tgbot.New(api, ingestor, wrk, tgbot.Config{
AllowedUserIDs: cfg.Telegram.AllowedUserIDs,
WebBaseURL: cfg.Telegram.WebBaseURL,
}, logger)
wrk.SetNotifier(bot)
go bot.Run(ctx)
logger.Info("telegram bot enabled",
"bot", api.Self.UserName, "allowed_users", len(cfg.Telegram.AllowedUserIDs))
}
}
go wrk.Run(ctx) go wrk.Run(ctx)
srv := &http.Server{ srv := &http.Server{
@@ -156,27 +209,58 @@ func runServe(args []string) error {
return nil return nil
} }
// telegramHTTPClient собирает HTTP-клиент бота с опц. прокси. Таймаута уровня
// клиента нет намеренно — он порвал бы long-poll; вместо этого ограничиваем
// установление соединения (dial/TLS из DefaultTransport) и ожидание заголовков
// ответа с запасом над long-poll (30с в tgbot). Так мёртвый прокси не подвешивает
// ни отправку уведомлений, ни приёмный цикл навсегда — клиент переподключится.
func telegramHTTPClient(proxy string) (*http.Client, error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
if proxy != "" {
proxyURL, err := url.Parse(proxy)
if err != nil {
return nil, fmt.Errorf("telegram: parse proxy %q: %w", proxy, err)
}
transport.Proxy = http.ProxyURL(proxyURL)
}
transport.ResponseHeaderTimeout = 45 * time.Second
return &http.Client{Transport: transport}, nil
}
// metadataProviders собирает включённые конфигом базы метаданных. Для // metadataProviders собирает включённые конфигом базы метаданных. Для
// сериалов Jellyfin привычнее tvdbid, поэтому TVDB идёт первым. // сериалов Jellyfin привычнее tvdbid, поэтому TVDB идёт первым.
func metadataProviders(cfg *config.Config) ([]metadata.Provider, error) { func metadataProviders(cfg *config.Config, logger *slog.Logger) ([]metadata.Provider, error) {
var out []metadata.Provider var out []metadata.Provider
if cfg.Metadata.TVDB.Enabled { // TVMaze без ключа и покрывает сериалы — ставим первым.
if cfg.Metadata.TVMaze.Enabled {
p, err := metadata.NewTVMaze(metadata.TVMazeConfig{
Proxy: cfg.Metadata.TVMaze.Proxy,
Timeout: cfg.Metadata.TVMaze.Timeout.Std(),
}, logger)
if err != nil {
return nil, fmt.Errorf("tvmaze provider: %w", err)
}
out = append(out, p)
}
// TVDB/TMDB включаются ключом: если enabled, но ключ пуст — тихо
// пропускаем (сервис стартует), а не падаем.
if cfg.Metadata.TVDB.Enabled && cfg.Metadata.TVDB.APIKey != "" {
p, err := metadata.NewTVDB(metadata.TVDBConfig{ p, err := metadata.NewTVDB(metadata.TVDBConfig{
APIKey: cfg.Metadata.TVDB.APIKey, APIKey: cfg.Metadata.TVDB.APIKey,
Proxy: cfg.Metadata.TVDB.Proxy, Proxy: cfg.Metadata.TVDB.Proxy,
Timeout: cfg.Metadata.TVDB.Timeout.Std(), Timeout: cfg.Metadata.TVDB.Timeout.Std(),
}) }, logger)
if err != nil { if err != nil {
return nil, fmt.Errorf("tvdb provider: %w", err) return nil, fmt.Errorf("tvdb provider: %w", err)
} }
out = append(out, p) out = append(out, p)
} }
if cfg.Metadata.TMDB.Enabled { if cfg.Metadata.TMDB.Enabled && cfg.Metadata.TMDB.APIKey != "" {
p, err := metadata.NewTMDB(metadata.TMDBConfig{ p, err := metadata.NewTMDB(metadata.TMDBConfig{
APIKey: cfg.Metadata.TMDB.APIKey, APIKey: cfg.Metadata.TMDB.APIKey,
Proxy: cfg.Metadata.TMDB.Proxy, Proxy: cfg.Metadata.TMDB.Proxy,
Timeout: cfg.Metadata.TMDB.Timeout.Std(), Timeout: cfg.Metadata.TMDB.Timeout.Std(),
}) }, logger)
if err != nil { if err != nil {
return nil, fmt.Errorf("tmdb provider: %w", err) return nil, fmt.Errorf("tmdb provider: %w", err)
} }
+20 -6
View File
@@ -1,14 +1,14 @@
# Пример конфигурации jellybit. Реальный config.toml рендерится Ansible'ом # Пример конфигурации jellybit. Реальный config.toml не коммитится (содержит
# из переменных umbar и не коммитится (секреты — vars/secrets.yml). # секреты). Для локального запуска: db_path -> ./jellybit.db.
# Для локального запуска: db_path -> ./jellybit.db.
[qbittorrent] [qbittorrent]
url = "http://qbit:8989" # по имени сервиса в общей docker-сети url = "http://qbit:8989" # по имени сервиса в общей docker-сети
username = "admin" username = "admin"
password = "" password = ""
category = "jellybit" category = "jellybit" # категория для добавляемых jellybit раздач (push)
tag = "jellybit" # тег для усыновления существующих раздач (pull, не двигает файлы)
savepath = "/srv/media/downloads" # qBit кладёт загрузки сюда (задаём при добавлении) savepath = "/srv/media/downloads" # qBit кладёт загрузки сюда (задаём при добавлении)
path_map = {} # фолбэк трансляции путей; обычно пуст path_map = {} # фолбэк: префикс save_path → хост-префикс, напр. {"/data" = "/srv/media"}; обычно пуст
[paths] [paths]
downloads = "/srv/media/downloads" downloads = "/srv/media/downloads"
@@ -40,6 +40,18 @@ api_key = ""
proxy = "" proxy = ""
timeout = "10s" timeout = "10s"
[metadata.tvmaze]
enabled = false # без ключа; только сериалы, тег [tvdbid-…] из externals
proxy = ""
timeout = "10s"
[jellyfin]
enabled = false # включить пересканирование медиатеки после раскладки
url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети
api_key = "" # API-ключ Jellyfin (Dashboard → API Keys)
proxy = "" # опц. HTTP-прокси
timeout = "10s"
[worker] [worker]
poll_interval = "5s" poll_interval = "5s"
stuck_after = "1h" stuck_after = "1h"
@@ -52,10 +64,12 @@ auto_confidence_threshold = 0.85
enabled = false enabled = false
token = "" token = ""
allowed_user_ids = [] # пусто = запрет всем (fail-closed) allowed_user_ids = [] # пусто = запрет всем (fail-closed)
web_base_url = "" # напр. "http://jellybit:8080" — для кнопки «открыть в вебе»
proxy = "" # опц. HTTP-прокси для api.telegram.org
[http] [http]
listen = ":8080" listen = ":8080"
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений trusted_subnets = [] # ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN); зарезервировано
[log] [log]
level = "info" level = "info"
@@ -0,0 +1,69 @@
# Авто-раскладка только при подтверждённом матче в метабазе
- Дата: 2026-06-13
## Контекст
jellybit распознаёт содержимое релиза через LLM по **недоверенным**
сигналам: имя торрента, текстовый контекст человека, распарсенное
сообщение бота — всё управляется извне и может содержать инъекции. По
результату распознавания нужно решить: разложить файлы хардлинками
автоматически или отправить на ревью человеку. Цена ошибки авто-раскладки
реальна — мусор в библиотеке Jellyfin под неверным названием/папкой,
возможно поверх чужого. Хочется максимум авто, но не ценой тихих ошибок.
Силы и ограничения:
- LLM хорошо разбирает русские и релиз-имена, но галлюцинирует, а его
самооценка (`confidence`) плохо откалибрована и тривиально поддаётся
инъекции из тех же недоверенных сигналов.
- Внешние базы (TMDB/TVDB/TVMaze) дают **независимый** авторитетный сигнал:
каноническое имя + `provider_id`. Но русские релизы и аниме часто в них
отсутствуют.
- Безопасность раскладки уже держится на валидации пути, не на промпте
(см. [recognition.md](../specs/recognition.md)); решение «авто vs review» —
второй слой защиты, на уровне доверия результату.
## Рассмотренные варианты
- **Гейт по самооценке LLM (`confidence ≥ порог`).** Просто и даёт
максимум авто. Но `confidence` не откалибрована и инъектируема —
«уверенный» неверный ответ прошёл бы молча. Небезопасно.
- **LLM + структурная валидация, без обязательной базы.** Ловит часть
ошибок (число файлов у фильма, дыры/дубли в нумерации S·E), но не ловит
«правильную структуру под неверным названием». Недостаточно как
единственный гейт авто.
- **Авто только при подтверждённом матче в базе + валидация +
согласованность сигналов.** Независимый авторитет снимает риск «LLM
придумал». Цена — рус/аниме (нет в базах) всегда идут в review, но это и
так нужный кейс.
## Решение
Авто-раскладку делаем, только если выполнено **всё**: (1) единственный
сильный матч в метабазе по названию+году, давший `provider_id`;
(2) структурная валидация без предупреждений; (3) пред-парс (`go-ptn`) и
LLM не противоречат по типу/названию/году. Нет матча или база выключена →
**всегда review**. Самооценку LLM учитываем лишь как вспомогательный
сигнал, не как гейт.
Почему так: безопасность держится на **независимой** проверке (база), а не
на доверии к выходу LLM, построенному из недоверенных данных. Это разом
закрывает основной кейс (рус/аниме отсутствуют в базах → человек
подтверждает) и убирает целый класс тихих ошибок «модель уверенно
ошиблась». Review здесь — не наказание, а штатный режим для всего, что
база не подтвердила (петля «догадка → подсказка → перераспознавание», см.
[review-ux.md](../specs/review-ux.md)). Полная модель уверенности — в
[recognition.md](../specs/recognition.md).
## Последствия
- `+` Нет тихих авто-ошибок раскладки: всё неподтверждённое видит человек.
- `+` `provider_id` из базы заодно даёт каноническое имя папки
(`[tmdbid-…]`) — Jellyfin не путает русские названия.
- `` Рус/аниме и всё, чего нет в базах, всегда требует ручного
подтверждения — авто там недоступно by design.
- `` Без включённых TMDB/TVDB/TVMaze авто-раскладки нет вовсе: сервис
работает в режиме «распознал → review».
- Делает цикл ревью критичным: если он неудобен, ручное подтверждение
станет узким местом — поэтому review-ux вынесен в отдельную спеку.
+1
View File
@@ -56,6 +56,7 @@
| Дата | Запись | Статус | | Дата | Запись | Статус |
| ---------- | ---------------------------------------------------------------- | ------ | | ---------- | ---------------------------------------------------------------- | ------ |
| 2026-06-13 | [Авто-раскладка только при матче в метабазе](ADR-2026-06-13-auto-link-requires-db-match.md) | — |
| 2026-06-13 | [Docker как единица деплоя](ADR-2026-06-13-docker-deploy.md) | — | | 2026-06-13 | [Docker как единица деплоя](ADR-2026-06-13-docker-deploy.md) | — |
| 2026-06-13 | [Хардлинки вместо копирования и симлинков](ADR-2026-06-13-hardlinks.md) | — | | 2026-06-13 | [Хардлинки вместо копирования и симлинков](ADR-2026-06-13-hardlinks.md) | — |
| 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.md) | — | | 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.md) | — |
+3 -1
View File
@@ -15,7 +15,9 @@
## Записи ## Записи
- [architecture.md](architecture.md) — общее устройство: компоненты, - [architecture.md](architecture.md) — общее устройство: компоненты,
поток, машина состояний, хранилище, конфигурация. транспорты, хранилище, раскладка, деплой.
- [workflow.md](workflow.md) — жизненный цикл загрузки: машина состояний,
переходы, сопоставление состояний qBittorrent.
- [recognition.md](recognition.md) — распознавание контента и модель - [recognition.md](recognition.md) — распознавание контента и модель
уверенности. уверенности.
- [review-ux.md](review-ux.md) — ревью раскладки человеком: UI/UX-сценарии - [review-ux.md](review-ux.md) — ревью раскладки человеком: UI/UX-сценарии
+63 -132
View File
@@ -36,47 +36,21 @@ qBittorrent, определяет содержимое (фильм или сер
| `worker` | владелец машины состояний; поллинг, сериализация команд | | `worker` | владелец машины состояний; поллинг, сериализация команд |
| `recognize` | пред-парс имени + вызов LLM + модель уверенности | | `recognize` | пред-парс имени + вызов LLM + модель уверенности |
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) | | `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
| `metadata` | интерфейс баз метаданных + TMDB/TVDB (опц.) | | `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) |
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo | | `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки | | `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
| `httpapi` | REST + веб-UI (server-rendered, htmx) | | `httpapi` | REST + веб-UI (server-rendered, POST-формы с redirect) |
| `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги | | `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги |
| `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) |
| `config` | загрузка TOML-конфига | | `config` | загрузка TOML-конфига |
## Поток и машина состояний ## Поток и машина состояний
``` Жизненный цикл загрузки (ingest → downloading → … → done/reverted),
ingest → downloading → completed → recognizing ──┬─ авто ────────────────→ linking → done полный граф состояний с переходами и сопоставление состояний qBittorrent —
│ │ │ └─ review ⇄ recognizing ─→ linking → done в отдельной спецификации [workflow.md](workflow.md). Ключевое: переходами
│ │ └─ moving/checking (ещё не готов) владеет `worker`, он же сериализует команды транспортов под per-download
│ └─ stuck (не качается дольше таймаута) блокировкой, а состояние персистентно в SQLite.
└─ failed ⇄ retry
done → undo → reverted
review → «Позже» → deferred → review
любой → «Отклонить» → cancelled
```
- **ingest** — приняли источник + контекст, отдали в qBittorrent
(категория `jellybit`), записали в БД с ключом идемпотентности.
- **downloading / completed** — `worker` поллит qBittorrent по категории
(`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте
(не `moving`/`checking*`), см. «Завершение в qBittorrent».
- **recognizing** — `recognize` строит план и оценку уверенности
([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM →
review (не failed).
- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл
`review ⇄ recognizing` — перераспознавание по подсказке.
- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем.
- **done** — опционально дёргаем скан Jellyfin; доступен **undo**
`reverted` (убрать созданные ссылки).
- **deferred / cancelled / failed / stuck** — «Позже», «Отклонить»,
ошибка (ретраибельна), не качается дольше таймаута.
Все переходы и команды идут через `worker` под per-download блокировкой —
два транспорта не гонятся за одно состояние. Состояние персистентно в
SQLite; на старте `worker` сверяет категорию qBittorrent с БД и
продолжает.
## Транспорты ## Транспорты
@@ -84,9 +58,10 @@ SQLite; на старте `worker` сверяет категорию qBittorrent
reject / defer / undo) — команды к `worker`: reject / defer / undo) — команды к `worker`:
- **HTTP API + веб-UI** — форма «добавить», список, экран ревью - **HTTP API + веб-UI** — форма «добавить», список, экран ревью
(server-rendered + htmx). В v1 **без авторизации** (доверенная LAN), с (server-rendered). В v1 **без авторизации** (доверенная LAN). Поле
опциональным allowlist подсетей (`http.trusted_subnets`). Защиту `http.trusted_subnets` зарезервировано, но **пока не применяется**:
навесим позже — [drafts/ideas.md](../drafts/ideas.md). деплой только в локальную сеть без доступа из интернета, поэтому
allowlist-middleware и авторизацию отложили — [drafts/ideas.md](../drafts/ideas.md).
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится - **Telegram-бот** — переслать magnet/сообщение бота; текст становится
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности. всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
@@ -104,8 +79,10 @@ SQLite. Схема покрывает приём, цикл ревью и отк
`idempotency_key`, состояние, `error_code`/`error_msg`, тайминги. `idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
(infohash может появиться позже приёма — для magnet без метаданных.) (infohash может появиться позже приёма — для magnet без метаданных.)
- `recognition` — попытки распознавания: `download_id`, `attempt_no`, - `recognition` — попытки распознавания: `download_id`, `attempt_no`,
`is_current`, тип, название, год, `provider` (`tmdb|tvdb|none`), `is_current`, тип, название, год, `provider` (`tmdb|tvdb|tvmaze|none`),
`provider_id`, `confidence`, причины-не-авто, сырой ответ LLM. `provider_id`, `confidence`, причины-не-авто, сырой ответ LLM и
структурированный `plan` (каноничный JSON `recognize.Plan` — файл →
роль/сезон/серия для превью и применения).
- `hint` — накопленные подсказки человека (`download_id`, текст, время). - `hint` — накопленные подсказки человека (`download_id`, текст, время).
- `override` — запиненные ручные правки полей (перераспознавание не - `override` — запиненные ручные правки полей (перераспознавание не
затирает). затирает).
@@ -128,73 +105,19 @@ qBittorrent. Идемпотентность — **только для актив
## Конфигурация ## Конфигурация
TOML. В репозитории — `config.example.toml` с placeholder'ами; реальный TOML. Полный список параметров с комментариями — в
`config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar [`config.example.toml`](../../config.example.toml) (источник истины, не
(секреты — `vars/secrets.yml` под ansible-vault), на диске **0600**, дублируем его здесь). Реальный `config.toml` рендерится при деплое
владелец `1000:1000`, не коммитится. Пример: Ansible-шаблоном из переменных umbar (секреты — `vars/secrets.yml` под
ansible-vault), на диске **0600**, владелец `1000:1000`, не коммитится.
```toml Структура секций: `[qbittorrent]` (доступ + категория/тег для push/pull),
[qbittorrent] `[paths]` (хост-пути песочницы), `[storage]` (путь к SQLite), `[llm]`
url = "http://qbit:8989" # по имени сервиса в общей docker-сети (провайдер распознавания, см. [recognition.md](recognition.md)),
username = "admin" `[metadata.tmdb|tvdb|tvmaze]` (опц. базы), `[jellyfin]` (опц.
password = "" пересканирование), `[worker]` (интервал поллинга и таймауты, см.
category = "jellybit" [workflow.md](workflow.md)), `[recognition]` (порог уверенности),
savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении) `[telegram]`, `[http]`, `[log]`.
# Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично,
# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся.
path_map = {}
[paths]
# хост-пути (видны внутри контейнера через mount /srv/media)
downloads = "/srv/media/downloads"
movies = "/srv/media/movies"
series = "/srv/media/series"
[llm]
# type — дискриминатор реализации; пока поддерживается "openai-compat"
type = "openai-compat"
# LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal,
# не 127.0.0.1; либо вынести LLM в контейнер общей сети.
base_url = "http://host.docker.internal:1234/v1"
api_key = ""
model = "qwen2.5-32b-instruct"
proxy = "" # опц. HTTP-прокси (для удалённых эндпоинтов)
timeout = "120s"
max_retries = 3 # непарсящийся ответ после ретраев → review
[metadata.tmdb]
enabled = false # включается ключом; без матча авто не делаем
api_key = ""
proxy = "" # опц. HTTP-прокси для доступа к базе
timeout = "10s"
[metadata.tvdb]
enabled = false
api_key = ""
proxy = ""
timeout = "10s"
[worker]
poll_interval = "5s" # как часто опрашивать qBittorrent
stuck_after = "1h" # не качается дольше → stuck
magnet_timeout = "30m" # magnet без метаданных дольше → failed
[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"
```
## Логирование ## Логирование
@@ -202,25 +125,6 @@ format = "json"
Каждая загрузка проходит со сквозным идентификатором; решения Каждая загрузка проходит со сквозным идентификатором; решения
распознавания (почему авто/ревью) и операции с файлами логируются явно. распознавания (почему авто/ревью) и операции с файлами логируются явно.
## Завершение в qBittorrent
`worker` опрашивает qBittorrent по категории и сопоставляет состояния:
- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`queuedUP`/
`forcedUP` — и **только** когда нет `moving`/`checkingUP`.
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`queuedDL`/
`checkingDL`/`forcedDL`.
- **застряло:** `metaDL` дольше `magnet_timeout`, `stalledDL` дольше
`stuck_after``stuck`/`failed`.
- **ошибка:** `error`/`missingFiles``failed`.
Пути файлов берём из API (`save_path`/`content_path` + относительные
имена), не из константы (обычно это уже хост-путь). «Incomplete»-каталог в
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
`moving` — дожидаемся окончания переноса и только потом берём финальный
путь).
## Раскладка файлов ## Раскладка файлов
`layout` создаёт хардлинки в `paths.movies`/`paths.series` по конвенциям `layout` создаёт хардлинки в `paths.movies`/`paths.series` по конвенциям
@@ -238,8 +142,13 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
доводит начатое (идемпотентно) либо откатывается. доводит начатое (идемпотентно) либо откатывается.
- **Undo** удаляет только ссылки своего `apply_batch_id` и только если - **Undo** удаляет только ссылки своего `apply_batch_id` и только если
путь под `paths.movies`/`series` — источник недосягаем. путь под `paths.movies`/`series` — источник недосягаем.
- **Одна ФС обязательна.** `link(2)` через границы ФС даёт `EXDEV` - **Хардлинк предпочтителен, но есть фолбэк.** По построению источник и
падаем с понятной ошибкой; по построению этого не должно случаться. цель — на одной ФС (единая песочница `/srv/media`), и `link(2)` проходит.
Если ФС всё же не поддерживает жёсткие ссылки или они между разными ФС
(`EXDEV`/`ENOTSUP`/`EOPNOTSUPP`/`EPERM`), `layout` **не падает**, а
копирует файл (через временный файл + атомарный `rename`) и пишет в лог
`Warn` (статус ссылки — `copied`): задача доходит до конца ценой
дублирования места. Источник при этом всё равно не трогаем.
### Пути и контейнеры — единая песочница `/srv/media` ### Пути и контейнеры — единая песочница `/srv/media`
@@ -262,10 +171,27 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
- **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`. - **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`.
- **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой - **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой
SQLite/конфиг — отдельным mount'ом `/srv/applications/jellybit/data`. SQLite — отдельным mount'ом `/srv/applications/jellybit/data`, конфиг —
отдельным `/srv/applications/jellybit/config`.
- **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень - **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень
`/srv/media`, иначе в индекс попадут downloads/incomplete). `/srv/media`, иначе в индекс попадут downloads/incomplete).
## Пересканирование Jellyfin
После успешной раскладки (вход в `done`) `worker` неблокирующе просит Jellyfin
пересканировать медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
Включается конфигом `[jellyfin]` (по умолчанию выключено); без него скан не
дёргается.
- **Один вызов — `POST /Library/Refresh`** (скан всех библиотек). Скан
инкрементальный, поэтому полный дёшев; точечный скан конкретной папки не
делаем — сложнее и не в духе сервиса («минимум компонентов»).
- **Авторизация** — API-ключ Jellyfin в заголовке `X-Emby-Token`.
- **Неблокирующе и вне `w.mu`** (как пинги Telegram): вызов уходит в сеть в
отдельной горутине с фоновым контекстом. Недоступность Jellyfin не влияет на
состояние задачи — ошибка лишь логируется (`Warn`).
- **Адресация** — по имени сервиса в общей docker-сети (`http://jellyfin:8096`).
## Деплой ## Деплой
Jellybit работает в **docker** — в одной среде с qBittorrent и Jellyfin Jellybit работает в **docker** — в одной среде с qBittorrent и Jellyfin
@@ -286,10 +212,13 @@ Jellybit работает в **docker** — в одной среде с qBittorr
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь - **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника. umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
- **mount `/srv/media`** (единая песочница) — для хардлинков и move - **mount `/srv/media`** (единая песочница) — для хардлинков и move
(см. «Пути и контейнеры»); `/srv/applications/jellybit/data` — отдельно. (см. «Пути и контейнеры»); каталоги jellybit — отдельно.
- **mount конфига** `/srv/applications/jellybit/config``/config` (ro):
`config.toml` (0600). Восстановим при деплое (рендерит плейбук umbar) —
бекапить не нужно.
- **mount данных** `/srv/applications/jellybit/data``/data`: SQLite - **mount данных** `/srv/applications/jellybit/data``/data`: SQLite
(`/data/jellybit.db`) и `config.toml`. Без него редеплой стёр бы всё (`/data/jellybit.db`). Бекапить-и-не-терять — без него редеплой стёр бы
in-flight состояние. всё in-flight состояние.
- **healthcheck** на `/healthz`. - **healthcheck** на `/healthz`.
Разделение ответственности: Разделение ответственности:
@@ -328,6 +257,9 @@ Dockerfile .dockerignore config.example.toml
задач (повторная закачка спустя время → новая задача). задач (повторная закачка спустя время → новая задача).
- Состояние — на persistent-томе `/srv/applications/jellybit/data`. - Состояние — на persistent-томе `/srv/applications/jellybit/data`.
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas). - Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
- Пересканирование Jellyfin после раскладки — `POST /Library/Refresh` (скан
всех библиотек, инкрементальный), неблокирующе на входе в `done`; опц.,
включается `[jellyfin]`.
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF. - Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
- Авто-раскладка требует подтверждённого матча в базе; иначе review. - Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей). - Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
@@ -336,5 +268,4 @@ Dockerfile .dockerignore config.example.toml
## Открытые вопросы ## Открытые вопросы
- Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin - (пока нет)
будет развёрнут в umbar (сейчас его там нет).
+8 -4
View File
@@ -37,8 +37,9 @@ series/
## Сопоставление источник → цель ## Сопоставление источник → цель
Источник берём по пути из qBittorrent (`save_path`/`content_path` + Источник берём по пути из qBittorrent (`save_path` + относительное имя
относительное имя; это уже хост-путь, `path_map` — фолбэк). Для каждого файла из `/torrents/files`, которое уже содержит корневую папку
многофайловой раздачи; это уже хост-путь, `path_map` — фолбэк). Для каждого
распознанного **файла** (не каталога) создаётся **хардлинк** в распознанного **файла** (не каталога) создаётся **хардлинк** в
`paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755, `paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755,
`1000:1000`). Исходный файл остаётся на месте (раздача продолжается), `1000:1000`). Исходный файл остаётся на месте (раздача продолжается),
@@ -50,8 +51,11 @@ inode общий — диск не дублируется.
же inode → готово; другой файл → коллизия → review). Инварианты и undo — же inode → готово; другой файл → коллизия → review). Инварианты и undo —
в [architecture.md](architecture.md) → «Раскладка файлов». в [architecture.md](architecture.md) → «Раскладка файлов».
Требование: целевой и исходный каталоги — на одной ФС/одном mount'е Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е
(внутри контейнера это обеспечивает единая песочница `/srv/media`). (внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда
работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без
поддержки жёстких ссылок), `layout` не падает, а копирует файл с
предупреждением в лог — см. architecture.md → «Раскладка файлов».
## Крайние случаи ## Крайние случаи
+24 -16
View File
@@ -5,15 +5,18 @@
По доступным сигналам определить: фильм или сериал; каноническое название По доступным сигналам определить: фильм или сериал; каноническое название
и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых
базах — провайдер и его id. На выходе — план раскладки, оценка уверенности базах — провайдер и его id. На выходе — план раскладки, оценка уверенности
и решение «авто или review». и решение «авто или review» (как оно встраивается в машину состояний —
[workflow.md](workflow.md), состояния `recognizing`/`linking`/`review`).
## Сигналы ## Сигналы
- Имя торрента и структура каталогов. - Имя торрента и структура каталогов.
- Список файлов с размерами и расширениями. Абсолютный путь источника - Список файлов с размерами и расширениями. Абсолютный путь источника
восстанавливаем как `save_path`/`content_path` из qBit (= хост-путь; восстанавливаем как `save_path` из qBit (= хост-путь; `path_map` обычно
`path_map` обычно тождественен) + относительное имя файла; учитываем тождественен) + относительное имя файла из `/torrents/files`. Имя уже
одно- и многофайловые торренты. включает корневую папку для многофайловых торрентов, поэтому префикс —
именно `save_path`, а не `content_path` (последний удвоил бы корневую
папку и сломал бы однофайловые раздачи).
- Текстовый контекст человека (+ накопленные подсказки из review). - Текстовый контекст человека (+ накопленные подсказки из review).
- Распарсенное сообщение торрент-бота (если через Telegram): название с - Распарсенное сообщение торрент-бота (если через Telegram): название с
годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md). годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md).
@@ -31,8 +34,10 @@
пред-парс, возвращает структурированный план в нашей схеме. Хорошо пред-парс, возвращает структурированный план в нашей схеме. Хорошо
берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под
контекст модели. контекст модели.
3. **Сверка с базой** (если включена TMDB/TVDB): ищем по названию+году, 3. **Сверка с базой** (если включена TMDB/TVDB/TVMaze): ищем по
берём официальный id и каноническое имя, собираем кандидатов. названию+году, берём официальный id и каноническое имя, собираем
кандидатов. TVMaze — без ключа, только сериалы; внешний id
(TVDB/IMDb) из `externals` идёт в имя папки.
4. **Оценка уверенности** и решение: авто или review. 4. **Оценка уверенности** и решение: авто или review.
## Структура ответа LLM (предварительная) ## Структура ответа LLM (предварительная)
@@ -52,8 +57,8 @@ notes пояснения, неоднозначности
Сезон/серия — **на файле**: так выражаются мультисезонные паки, Сезон/серия — **на файле**: так выражаются мультисезонные паки,
спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет. спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет.
`provider_hint` — только подсказка для поиска; итоговые `provider` `provider_hint` — только подсказка для поиска; итоговые `provider`
(`tmdb|tvdb|none`) и `provider_id` появляются после сверки с базой и (`tmdb|tvdb|tvmaze|none`) и `provider_id` появляются после сверки с базой
хранятся отдельно. и хранятся отдельно.
## Провайдер LLM ## Провайдер LLM
@@ -65,22 +70,25 @@ notes пояснения, неоднозначности
Chat Completions API (`base_url` + `api_key` + `model`). Подходят Chat Completions API (`base_url` + `api_key` + `model`). Подходят
локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые
провайдеры (DeepSeek, Qwen и др.). провайдеры (DeepSeek, Qwen и др.).
- **Структурированный вывод надёжно:** просим JSON по схеме - **Структурированный вывод надёжно:** просим JSON-режим
(`response_format` со схемой где поддерживается; иначе json-режим или (`response_format: {"type":"json_object"}`) — это поддерживают и мелкие
tool-call); на приёме срезаем ```-ограждения и извлекаем JSON, локальные модели, в отличие от строгих JSON Schema. На приёме срезаем
**валидируем в Go**, ретраим со схемой-в-промпте до `llm.max_retries`; ```-ограждения и извлекаем JSON, **валидируем в Go** против нашей схемы;
если так и не распарсилось — уходим в **review** (не в `failed`) с при ошибке разбора ретраим, передавая модели саму ошибку и схему в
причиной «ответ LLM не разобран». Серверы заметно различаются по промпте, до `llm.max_retries`. Если так и не распарсилось — уходим в
поддержке строгих схем, особенно мелкие локальные модели. **review** (не в `failed`) с причиной «ответ LLM не разобран».
- Новые типы (напр. нативный `anthropic`) добавляются, не трогая - Новые типы (напр. нативный `anthropic`) добавляются, не трогая
`recognize`. `recognize`.
## Модель уверенности ## Модель уверенности
Почему авто только при матче в базе, а не по самооценке LLM —
[ADR-2026-06-13-auto-link-requires-db-match](../adr/ADR-2026-06-13-auto-link-requires-db-match.md).
Авто-раскладка — только если выполнено **всё**: Авто-раскладка — только если выполнено **всё**:
1. **Подтверждённый матч в базе** — единственный сильный результат 1. **Подтверждённый матч в базе** — единственный сильный результат
TMDB/TVDB по названию+году, давший `provider_id`. **Нет матча (или TMDB/TVDB/TVMaze по названию+году, давший `provider_id`. **Нет матча (или
база выключена) → всегда review.** Это и закрывает основной кейс база выключена) → всегда review.** Это и закрывает основной кейс
(рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал». (рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал».
2. **Структурная валидация** без предупреждений: 2. **Структурная валидация** без предупреждений:
+28 -10
View File
@@ -2,7 +2,8 @@
Что происходит, когда система не уверена в распознавании и не Что происходит, когда система не уверена в распознавании и не
раскладывает файлы автоматически. Когда именно наступает ревью — см. раскладывает файлы автоматически. Когда именно наступает ревью — см.
[recognition.md](recognition.md); конвенции целевых имён [recognition.md](recognition.md); место состояния `review` в общем потоке
[workflow.md](workflow.md); конвенции целевых имён —
[jellyfin-layout.md](jellyfin-layout.md). [jellyfin-layout.md](jellyfin-layout.md).
Главный принцип: ревью — это **петля «догадка → подсказка человека → Главный принцип: ревью — это **петля «догадка → подсказка человека →
@@ -82,9 +83,13 @@ Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟡 review
- **🔁 Уточнить** → бот просит подсказку ответом → перераспознаёт → - **🔁 Уточнить** → бот просит подсказку ответом → перераспознаёт →
редактирует то же сообщение новым планом. Петля коррекции прямо в чате. редактирует то же сообщение новым планом. Петля коррекции прямо в чате.
- **🔢 Выбрать в базе** → кнопки по кандидатам (название · год · id). - Точечное переназначение файлов и выбор кандидата базы в чат не
- Точечное переназначение файлов в чат не помещается → **🌐 Открыть в помещаются → **🌐 В вебе** (deep-link на ту же страницу, строится из
вебе** (deep-link на ту же страницу). `telegram.web_base_url`).
> Реально в боте сейчас: ✅ Применить, 📺↔🎬 Тип, 🔁 Уточнить, 🕗 Позже,
> 🌐 В вебе, ❌ Отклонить. Кнопки «🔢 Выбрать в базе» в чате пока нет —
> выбор кандидата и ручной ввод id делаются в вебе.
## Разделение труда ## Разделение труда
@@ -122,12 +127,25 @@ Telegram = одобрить / подсказать / выбрать кандид
- **«Позже»** паркует загрузку в `deferred` (вернётся в review по - **«Позже»** паркует загрузку в `deferred` (вернётся в review по
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo** действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
после применения → `reverted` (удаляет только ссылки своего батча, под после применения → `reverted` (удаляет только ссылки своего батча, под
`media`). Полная карта состояний — в [architecture.md](architecture.md). `media`). Полная карта состояний — в [workflow.md](workflow.md).
- После отката или отклонения доступна **«Привязать заново»**: перезапускает
распознавание для той же раздачи (`reverted`/`cancelled → recognizing`) и
снова приводит в review — раскладка всегда требует ручного подтверждения,
авто не делаем. Нужна, когда распознали неверно: откатил/отклонил,
перепривязал, поправил и применил.
- В самом ревью, помимо **«Уточнить»** (подсказка + перераспознавание), есть
**«Распознать заново»** — повторный прогон распознавания без новой подсказки
(контекст и прежние подсказки уже учтены). Полезно, когда модель один раз
споткнулась на разовой ошибке.
## Объём по версиям ## Объём по версиям
- **Ф3 (первая версия):** подсказка + перераспознавание; из ручного — - **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
переключатель типа, выбор кандидата базы, пометка файла «игнор». Undo — заново», переключатель типа, выбор кандидата базы / ручной ввод id /
есть. «без базы», пометка файла «игнор», «Применить»/«Отклонить»/«Позже»,
- **Ф5:** полный редактор маппинга «файл → серия», ручной режим, Undo и «Привязать заново». В Telegram — подтверждение с reply-подсказкой
подтверждение в Telegram с reply-подсказкой и эскалацией в веб. («Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб;
пинги о входе в review и готовности.
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
(правка S·E, «нумеровать подряд»), ручной режим при полном провале LLM,
выбор кандидата базы и ввод id прямо в Telegram.
+121
View File
@@ -0,0 +1,121 @@
# Жизненный цикл загрузки и машина состояний
Как загрузка проходит путь от приёма источника до разложенных файлов:
состояния, переходы и то, что их вызывает. Кто владеет переходами и общее
устройство — в [architecture.md](architecture.md); детали распознавания —
в [recognition.md](recognition.md); действия человека в ревью — в
[review-ux.md](review-ux.md).
## Граф состояний
```mermaid
stateDiagram-v2
[*] --> downloading: ingest (источник отдан в qBittorrent)
downloading --> completed: файлы на месте
downloading --> stuck: stalledDL дольше stuck_after
downloading --> failed: metaDL дольше magnet_timeout / error
completed --> recognizing
recognizing --> linking: авто (матч в базе + валидация)
recognizing --> review: нужно подтверждение / ответ LLM не разобран
review --> linking: Применить
review --> recognizing: Уточнить / Распознать заново
review --> deferred: Позже
review --> cancelled: Отклонить
deferred --> review: любое действие (та же поверхность)
linking --> done
linking --> review: коллизия цели
linking --> failed: ошибка ФС
done --> reverted: Undo
reverted --> recognizing: Привязать заново
cancelled --> recognizing: Привязать заново
stuck --> downloading: Retry
failed --> downloading: Retry
done --> [*]
cancelled --> [*]
reverted --> [*]
note right of cancelled
«Отклонить» доступно из любого
нетерминального состояния
end note
```
Условно-терминальные состояния — `done`, `cancelled`, `failed`,
`reverted`: задача в них останавливается, но из `failed`/`stuck` есть
**Retry**, а из `reverted`/`cancelled`**Привязать заново**. `stuck`
восстановимо ретраем.
## Состояния и переходы
- **ingest → downloading** — приняли источник + контекст, отдали в
qBittorrent (категория `qbittorrent.category`), записали в БД с ключом
идемпотентности. См. [architecture.md](architecture.md) → «Транспорты».
- **downloading / completed**`worker` поллит qBittorrent
(`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте
(не `moving`/`checking*`), см. «Завершение в qBittorrent» ниже.
- **recognizing**`recognize` строит план и оценку уверенности
([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM →
review (не failed).
- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл
`review ⇄ recognizing` — перераспознавание по подсказке. «Уточнить» —
подсказка + перераспознавание; «Распознать заново» — повторный прогон
без новой подсказки, по уже накопленному контексту и подсказкам.
- **deferred** — «Позже» паркует задачу; принимает те же команды, что и
`review`, и возвращается в поверхность ревью по любому действию.
- **linking**`layout` создаёт хардлинки; идемпотентно, батчем. Коллизия
цели возвращает в review, ошибка ФС → failed. См.
[architecture.md](architecture.md) → «Раскладка файлов».
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
(опц., см. [architecture.md](architecture.md) → «Пересканирование
Jellyfin»); доступен **Undo**`reverted` (убрать созданные ссылки).
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
(ретраибельна); «Отклонить».
- **reverted / cancelled → recognizing** — «Привязать заново»: после
отката или отклонения можно перезапустить распознавание для той же
раздачи. Перепривязка всегда идёт через review с ручным подтверждением
(авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в
qBittorrent.
Все переходы и команды идут через `worker` под per-download блокировкой —
два транспорта не гонятся за одно состояние. Состояние персистентно в
SQLite; `worker` периодически сверяет qBittorrent с БД и **усыновляет**
раздачи с нашей категорией (`qbittorrent.category`) **или** тегом
(`qbittorrent.tag`), которых ещё нет в БД, заводя для них задачу в
состоянии `downloading`. Категория ставится на добавляемые нами раздачи
(push, задаёт savepath); тег позволяет подхватить уже существующую
раздачу, не трогая её категорию и файлы (pull).
## Завершение в qBittorrent
`worker` опрашивает qBittorrent и сопоставляет его состояния с нашими:
- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`stoppedUP`/
`queuedUP`/`forcedUP` (имена `paused*`/`stopped*` различаются между qBit
v4 и v5 — поддержаны оба).
- **переходное, ждём:** `moving`/`checkingUP`/`checkingResumeData`/
`allocating` — остаёмся в `downloading`, пока qBit не закончит перенос/
проверку (готовность не объявляем, даже если флаги «UP»).
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/
`queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`.
- **застряло/ошибка по таймауту:** `metaDL`/`forcedMetaDL` дольше
`magnet_timeout``failed`; `stalledDL` дольше `stuck_after``stuck`
(восстановимо ретраем). Возраст считаем от создания задачи.
- **ошибка:** `error`/`missingFiles``failed`.
Пути файлов берём из API (`save_path` + относительные имена из
`/torrents/files`, уже включающие корневую папку торрента), не из
константы (обычно это уже хост-путь). «Incomplete»-каталог в
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
`moving` — дожидаемся окончания переноса и только потом берём финальный
путь). Подробнее о путях и песочнице — [architecture.md](architecture.md)
→ «Пути и контейнеры».
+129
View File
@@ -0,0 +1,129 @@
# TODO
Конкретные задачи на будущее, ранжированные по приоритету. Это не план
реализации (он — в [drafts/roadmap.md](drafts/roadmap.md)) и не свалка
идей ([drafts/ideas.md](drafts/ideas.md)): сюда попадает то, что уже решили
сделать, но ещё не сделали. Принятое и реализованное переезжает в
`docs/specs`/`docs/adr`.
Приоритет — грубая оценка «ценность / стоимость», не обязательство к
порядку.
## Высокий
### Проблема второго сезона
Если первый сезон сериала уже разложен, а мы добавляем второй/третий/…,
распознавание должно привязать новый сезон к **тому же** названию и папке,
а не завести рядом почти одинаковую вторую папку. Ключ — стабильный
`provider_id`: один и тот же `[tvdbid-…]` → одна папка сериала, новые
`Season NN` доливаются внутрь. Нужно: при матче учитывать уже существующие
в библиотеке сериалы (или прошлые распознавания с тем же провайдер-id) и
склонять LLM/выбор кандидата к согласованности с ними.
Связано: [recognition.md](specs/recognition.md) (модель уверенности,
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
сериала с провайдер-id).
### Название из контекста при добавлении в qBittorrent
При создании magnet-загрузки передавать в qBittorrent человекочитаемое имя
из контекста (если оно есть), чтобы в списке qBit не было безликих
`rutracker-topic-6852853`. Небольшая задача с заметной отдачей в
повседневной эксплуатации.
Связано: [architecture.md](specs/architecture.md) → «Транспорты», пакет
`ingest`/`qbt`.
### Рассинхрон состояния с реальностью (удалённый торрент / файлы)
Состояние jellybit может разойтись с тем, что реально лежит на диске.
Несколько сценариев разной остроты:
- **Жёсткий — удалён источник.** Раздачу удаляют (вручную или авто по
достижении seed limit), и qBittorrent стирает скачанные файлы. Тогда
хардлинк в библиотеке становится **последней** ссылкой на inode, и
обычный `undo` (`unlink` цели + чистка пустых каталогов) сотрёт
единственную копию насовсем — прямая потеря данных. Инвариант «источник
неприкосновенен» молчаливо перестаёт держаться: источника уже нет.
- **Мягкий — удалена цель.** Файлы убрали из библиотеки Jellyfin (вручную
или из самого Jellyfin), а jellybit по-прежнему числит загрузку в
`done`. Состояние врёт: ссылок уже нет, а сервис думает, что всё
разложено.
Нужно продумать сверку записанного состояния (`file_link`, состояние
загрузки) с фактом на ФС:
- как `worker` реагирует на исчезновение раздачи из qBittorrent
(состояние/пометка загрузки);
- как `undo` защищается, когда источник недоступен — например,
отказываться удалять, если у целевого файла счётчик ссылок == 1 (нет
второй копии) или исходный путь не существует, и явно об этом сообщать.
Откат снимает **лишний** хардлинк, а не последнюю копию файла;
- как ловить пропажу целевых файлов и отражать её в состоянии (напр.
периодическая сверка или проверка при показе — «разложено, но файлов
нет»), чтобы можно было осознанно перепривязать/переразложить.
Связано: [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md),
[architecture.md](specs/architecture.md) → «Раскладка файлов» (undo,
инвариант источника), [workflow.md](specs/workflow.md) (`done → reverted`).
## Средний
### Машина состояний на go-библиотеке
Сейчас FSM реализована вручную в `worker`. Выбрать подходящую go-библиотеку
для описания воркфлоу/машины состояний и перевести переходы на неё — ради
декларативности, проверяемости переходов и единого места правды. Кандидаты
для оценки: `looplab/fsm`, `qmuntal/stateless` (и аналоги). Граф и переходы
уже формализованы — переносим один в один.
Связано: [workflow.md](specs/workflow.md) (текущий граф состояний).
### Привязка уведомлений к источнику в ботах (мульти-бот)
Уведомления и запросы подтверждения должен получать тот, кто прислал
загрузку: автор сообщения о новой раздаче — адресат пингов и ревью по ней.
Транспортов-ботов может быть несколько (Telegram, в перспективе Matrix и
др.); каждый адресует «своему» отправителю. Веб-интерфейс остаётся
**единым для всех** и точкой правды по функциональности (боты — тонкие
адаптеры над тем же ядром). Нужно: хранить у загрузки источник/транспорт и
идентификатор отправителя, маршрутизировать пинги по нему.
Связано: [review-ux.md](specs/review-ux.md) (разделение труда транспортов,
веб = точные правки), [architecture.md](specs/architecture.md) →
«Транспорты».
### Добавление торрентов файлом/ссылкой — «единое окно»
Поддержать источники помимо magnet: `.torrent`-файл и URL (отдаём их в
qBittorrent, без исходящих запросов на пользовательский URL — SSRF
исключён). Идеал — одно поле «единого окна»: кидаем туда текст или файл, а
сервис сам разбирает, что это (magnet / ссылка / .torrent / сообщение
бота), и заводит загрузку.
Связано: [architecture.md](specs/architecture.md) → «Транспорты»
(`source_type = magnet|torrent|url` уже в схеме), пакет `ingest` (сейчас
поддержан только magnet).
## Низкий
### Многоступенчатая верификация привязки (тема для размышления)
Идея: несколько раз извлекать данные из раздачи и контекста разными
промптами, искать в метабазах, затем сводить результаты в общий вердикт
(голосование/консенсус) — выше точность ценой нескольких вызовов LLM и
запросов к базам. Требует проработки: когда включать, как мерджить
расхождения, стоимость/латентность.
Связано: [recognition.md](specs/recognition.md) (конвейер и модель
уверенности).
### Современный Web-UI как PWA
Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое,
отзывчивое, удобное с телефона). Текущий server-rendered UI функционален,
поэтому это улучшение, а не блокер; большой объём работы.
Связано: [review-ux.md](specs/review-ux.md) (веб = точные правки),
пакет `httpapi`.
+1
View File
@@ -4,6 +4,7 @@ go 1.26
require ( require (
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.1.0
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4 github.com/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4
github.com/pelletier/go-toml/v2 v2.2.3 github.com/pelletier/go-toml/v2 v2.2.3
+2
View File
@@ -8,6 +8,8 @@ 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-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 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc=
github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+25 -1
View File
@@ -17,6 +17,7 @@ type Config struct {
Storage Storage `toml:"storage"` Storage Storage `toml:"storage"`
LLM LLM `toml:"llm"` LLM LLM `toml:"llm"`
Metadata Metadata `toml:"metadata"` Metadata Metadata `toml:"metadata"`
Jellyfin Jellyfin `toml:"jellyfin"`
Worker Worker `toml:"worker"` Worker Worker `toml:"worker"`
Recognition Recognition `toml:"recognition"` Recognition Recognition `toml:"recognition"`
Telegram Telegram `toml:"telegram"` Telegram Telegram `toml:"telegram"`
@@ -29,7 +30,12 @@ type QBittorrent struct {
URL string `toml:"url"` URL string `toml:"url"`
Username string `toml:"username"` Username string `toml:"username"`
Password string `toml:"password"` Password string `toml:"password"`
// Category — категория для добавляемых jellybit раздач (push, savepath).
Category string `toml:"category"` Category string `toml:"category"`
// Tag — метка для усыновления существующих раздач (pull, не трогает
// категорию/savepath). Discovery подхватывает раздачи с этой категорией
// ИЛИ этим тегом.
Tag string `toml:"tag"`
SavePath string `toml:"savepath"` SavePath string `toml:"savepath"`
PathMap map[string]string `toml:"path_map"` PathMap map[string]string `toml:"path_map"`
} }
@@ -61,9 +67,11 @@ type LLM struct {
type Metadata struct { type Metadata struct {
TMDB MetadataProvider `toml:"tmdb"` TMDB MetadataProvider `toml:"tmdb"`
TVDB MetadataProvider `toml:"tvdb"` TVDB MetadataProvider `toml:"tvdb"`
TVMaze MetadataProvider `toml:"tvmaze"` // без ключа, только сериалы
} }
// MetadataProvider — настройки одного провайдера метаданных. // MetadataProvider — настройки одного провайдера метаданных. У keyless-баз
// (TVMaze) поле api_key не используется.
type MetadataProvider struct { type MetadataProvider struct {
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
APIKey string `toml:"api_key"` APIKey string `toml:"api_key"`
@@ -71,6 +79,16 @@ type MetadataProvider struct {
Timeout Duration `toml:"timeout"` Timeout Duration `toml:"timeout"`
} }
// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
// конфигом; без него скан не дёргается.
type Jellyfin struct {
Enabled bool `toml:"enabled"`
URL string `toml:"url"`
APIKey string `toml:"api_key"`
Proxy string `toml:"proxy"` // опц. HTTP-прокси
Timeout Duration `toml:"timeout"`
}
// Worker — параметры фонового цикла. // Worker — параметры фонового цикла.
type Worker struct { type Worker struct {
PollInterval Duration `toml:"poll_interval"` PollInterval Duration `toml:"poll_interval"`
@@ -88,11 +106,16 @@ type Telegram struct {
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
Token string `toml:"token"` Token string `toml:"token"`
AllowedUserIDs []int64 `toml:"allowed_user_ids"` AllowedUserIDs []int64 `toml:"allowed_user_ids"`
WebBaseURL string `toml:"web_base_url"` // для deep-link «открыть в вебе» (опц.)
Proxy string `toml:"proxy"` // опц. HTTP-прокси для api.telegram.org
} }
// HTTP — параметры веб-сервера. // HTTP — параметры веб-сервера.
type HTTP struct { type HTTP struct {
Listen string `toml:"listen"` Listen string `toml:"listen"`
// TrustedSubnets — allowlist подсетей. ПОКА НЕ ПРИМЕНЯЕТСЯ: деплой только
// в локальную сеть без доступа из интернета, поэтому middleware отложено
// (см. architecture.md). Поле сохранено под будущую реализацию.
TrustedSubnets []string `toml:"trusted_subnets"` TrustedSubnets []string `toml:"trusted_subnets"`
} }
@@ -143,6 +166,7 @@ func Default() *Config {
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)}, TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)}, TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
}, },
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
Worker: Worker{ Worker: Worker{
PollInterval: Duration(5 * time.Second), PollInterval: Duration(5 * time.Second),
StuckAfter: Duration(time.Hour), StuckAfter: Duration(time.Hour),
+27 -2
View File
@@ -13,6 +13,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -85,10 +86,15 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Get("/review/{id}", s.handleReview) r.Get("/review/{id}", s.handleReview)
r.Post("/ui/downloads/{id}/apply", s.handleApply) r.Post("/ui/downloads/{id}/apply", s.handleApply)
r.Post("/ui/downloads/{id}/refine", s.handleRefine) r.Post("/ui/downloads/{id}/refine", s.handleRefine)
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
r.Post("/ui/downloads/{id}/type", s.handleSetType) r.Post("/ui/downloads/{id}/type", s.handleSetType)
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore) r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
r.Post("/ui/downloads/{id}/defer", s.handleDefer) r.Post("/ui/downloads/{id}/defer", s.handleDefer)
r.Post("/ui/downloads/{id}/undo", s.handleUndo) r.Post("/ui/downloads/{id}/undo", s.handleUndo)
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
// REST API. // REST API.
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
@@ -123,6 +129,7 @@ type downloadView struct {
Terminal bool Terminal bool
Reviewable bool // review/deferred — есть экран ревью Reviewable bool // review/deferred — есть экран ревью
Undoable bool // done — можно откатить раскладку Undoable bool // done — можно откатить раскладку
Relinkable bool // reverted/cancelled — можно перепривязать заново
} }
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
@@ -262,6 +269,7 @@ func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(con
return return
} }
if err := cmd(r.Context(), id); err != nil { if err := cmd(r.Context(), id); err != nil {
s.deps.Logger.Warn("api command failed", "path", r.URL.Path, "id", id, "err", err)
writeJSON(w, http.StatusConflict, errJSON(err)) writeJSON(w, http.StatusConflict, errJSON(err))
return return
} }
@@ -300,6 +308,7 @@ func toView(d store.Download) downloadView {
Terminal: d.State.IsTerminal(), Terminal: d.State.IsTerminal(),
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
Undoable: d.State == store.StateDone, Undoable: d.State == store.StateDone,
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled,
} }
} }
@@ -332,7 +341,9 @@ func errJSON(err error) map[string]string {
return map[string]string{"error": err.Error()} return map[string]string{"error": err.Error()}
} }
// requestLogger пишет структурированный лог по каждому запросу. // requestLogger пишет структурированный лог по каждому запросу. Частые
// служебные запросы (healthcheck, GET-страницы веб-UI с авто-рефрешем) пишем
// на DEBUG, чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler { func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -341,7 +352,7 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
next.ServeHTTP(ww, r) next.ServeHTTP(ww, r)
logger.Info("http request", logger.Log(r.Context(), requestLogLevel(r), "http request",
"method", r.Method, "method", r.Method,
"path", r.URL.Path, "path", r.URL.Path,
"status", ww.Status(), "status", ww.Status(),
@@ -352,3 +363,17 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
}) })
} }
} }
// requestLogLevel понижает уровень для частых служебных запросов: healthcheck
// и GET-страницы веб-UI (список авто-рефрешится каждые 5 с). Мутации и REST
// API (`/api/...`) остаются на INFO.
func requestLogLevel(r *http.Request) slog.Level {
switch {
case r.URL.Path == "/healthz":
return slog.LevelDebug
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api"):
return slog.LevelDebug
default:
return slog.LevelInfo
}
}
+108 -1
View File
@@ -2,6 +2,7 @@ package httpapi_test
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"io" "io"
"log/slog" "log/slog"
@@ -187,9 +188,14 @@ type fakeReviewer struct {
refined map[int64]string refined map[int64]string
typed map[int64]string typed map[int64]string
ignored map[int64]string ignored map[int64]string
chosen map[int64]int64
providerSet map[int64]string
applied []int64 applied []int64
deferred []int64 deferred []int64
undone []int64 undone []int64
relinked []int64
rerecognized []int64
cleared []int64
} }
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) { func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
@@ -231,6 +237,32 @@ func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
f.undone = append(f.undone, id) f.undone = append(f.undone, id)
return nil return nil
} }
func (f *fakeReviewer) Relink(_ context.Context, id int64) error {
f.relinked = append(f.relinked, id)
return nil
}
func (f *fakeReviewer) Rerecognize(_ context.Context, id int64) error {
f.rerecognized = append(f.rerecognized, id)
return nil
}
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
if f.chosen == nil {
f.chosen = map[int64]int64{}
}
f.chosen[id] = candidateID
return nil
}
func (f *fakeReviewer) SetProviderID(_ context.Context, id int64, provider, providerID string) error {
if f.providerSet == nil {
f.providerSet = map[int64]string{}
}
f.providerSet[id] = provider + ":" + providerID
return nil
}
func (f *fakeReviewer) ClearProvider(_ context.Context, id int64) error {
f.cleared = append(f.cleared, id)
return nil
}
func seriesReviewData() *worker.ReviewData { func seriesReviewData() *worker.ReviewData {
s, e := 2, 1 s, e := 2, 1
@@ -248,6 +280,11 @@ func seriesReviewData() *worker.ReviewData {
Preview: []layout.Link{ Preview: []layout.Link{
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"}, {Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
}, },
Candidates: []store.MetadataCandidate{
{ID: 10, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
Year: sql.NullInt64{Int64: 2014, Valid: true}},
{ID: 11, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
},
Hints: []string{"второй сезон"}, Hints: []string{"второй сезон"},
} }
} }
@@ -274,13 +311,55 @@ func TestReviewRenders(t *testing.T) {
t.Fatalf("status = %d", resp.StatusCode) t.Fatalf("status = %d", resp.StatusCode)
} }
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv", for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
"Season 02", "Применить", "Уточнить"} { "Season 02", "Применить", "Уточнить",
"База метаданных", "269613", "выбрать", "Без базы"} {
if !strings.Contains(string(body), want) { if !strings.Contains(string(body), want) {
t.Errorf("страница ревью не содержит %q", want) t.Errorf("страница ревью не содержит %q", want)
} }
} }
} }
func TestChooseCandidate(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/candidate",
map[string][]string{"candidate_id": {"10"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if rv.chosen[1] != 10 {
t.Errorf("ChooseCandidate получил %d", rv.chosen[1])
}
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
t.Errorf("Location = %q", loc)
}
}
func TestSetProviderAndNoBase(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/provider",
map[string][]string{"provider": {"tvdb"}, "provider_id": {"269613"}}); err != nil {
t.Fatal(err)
}
if rv.providerSet[1] != "tvdb:269613" {
t.Errorf("SetProviderID получил %q", rv.providerSet[1])
}
if _, err := cl.Post(srv.URL+"/ui/downloads/1/nobase", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.cleared) != 1 || rv.cleared[0] != 1 {
t.Errorf("ClearProvider = %v", rv.cleared)
}
}
func TestApplyRedirectsToIndex(t *testing.T) { func TestApplyRedirectsToIndex(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()} rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
@@ -375,3 +454,31 @@ func TestUndoAndDefer(t *testing.T) {
t.Errorf("undo=%v defer=%v", rv.undone, rv.deferred) t.Errorf("undo=%v defer=%v", rv.undone, rv.deferred)
} }
} }
func TestRelink(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
if _, err := cl.Post(srv.URL+"/ui/downloads/1/relink", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.relinked) != 1 || rv.relinked[0] != 1 {
t.Errorf("relinked = %v, want [1]", rv.relinked)
}
}
func TestRerecognize(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
if _, err := cl.Post(srv.URL+"/ui/downloads/1/rerecognize", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != 1 {
t.Errorf("rerecognized = %v, want [1]", rv.rerecognized)
}
}
@@ -0,0 +1,27 @@
package httpapi
import (
"log/slog"
"net/http/httptest"
"testing"
)
func TestRequestLogLevel(t *testing.T) {
cases := []struct {
method, path string
want slog.Level
}{
{"GET", "/healthz", slog.LevelDebug}, // healthcheck — тихо
{"GET", "/", slog.LevelDebug}, // список (авто-рефреш)
{"GET", "/review/1", slog.LevelDebug}, // страница ревью
{"GET", "/api/downloads", slog.LevelInfo}, // REST API — на INFO
{"POST", "/ui/downloads/1/apply", slog.LevelInfo}, // мутация — на INFO
{"POST", "/api/downloads", slog.LevelInfo}, // приём — на INFO
}
for _, c := range cases {
r := httptest.NewRequest(c.method, c.path, nil)
if got := requestLogLevel(r); got != c.want {
t.Errorf("%s %s: level=%v, want %v", c.method, c.path, got, c.want)
}
}
}
+85 -3
View File
@@ -20,6 +20,11 @@ type Reviewer interface {
IgnoreFile(ctx context.Context, id int64, src string) error IgnoreFile(ctx context.Context, id int64, src string) error
Defer(ctx context.Context, id int64) error Defer(ctx context.Context, id int64) error
Undo(ctx context.Context, id int64) error Undo(ctx context.Context, id int64) error
Relink(ctx context.Context, id int64) error
Rerecognize(ctx context.Context, id int64) error
ChooseCandidate(ctx context.Context, id, candidateID int64) error
SetProviderID(ctx context.Context, id int64, provider, providerID string) error
ClearProvider(ctx context.Context, id int64) error
} }
// --- Представление страницы ревью --- // --- Представление страницы ревью ---
@@ -44,6 +49,8 @@ type reviewView struct {
Files []reviewFileView Files []reviewFileView
Preview []string Preview []string
HasPlan bool HasPlan bool
NoBase bool // выбрано «без базы»
Candidates []candidateView
} }
type reviewFileView struct { type reviewFileView struct {
@@ -54,6 +61,15 @@ type reviewFileView struct {
Ignored bool Ignored bool
} }
type candidateView struct {
ID int64
Provider string
ProviderID string
Title string
Year int
Chosen bool
}
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) { func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
@@ -87,9 +103,12 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
view.OriginalTitle = rd.Plan.OriginalTitle view.OriginalTitle = rd.Plan.OriginalTitle
view.Year = rd.Plan.Year view.Year = rd.Plan.Year
view.Reasons = rec.ReasonList() view.Reasons = rec.ReasonList()
if rec.Provider.Valid && rec.Provider.String != "none" { switch rd.Provider {
view.Provider = rec.Provider.String case "", "none":
view.ProviderID = rec.ProviderID.String view.NoBase = rd.Provider == "none"
default:
view.Provider = rd.Provider
view.ProviderID = rd.ProviderID
} }
if rec.Confidence.Valid { if rec.Confidence.Valid {
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64) view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
@@ -104,6 +123,16 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
}) })
} }
view.HasPlan = len(rd.Plan.Files) > 0 view.HasPlan = len(rd.Plan.Files) > 0
for _, c := range rd.Candidates {
view.Candidates = append(view.Candidates, candidateView{
ID: c.ID,
Provider: c.Provider,
ProviderID: c.ProviderID,
Title: c.Title.String,
Year: int(c.Year.Int64),
Chosen: c.Chosen,
})
}
} }
for _, l := range rd.Preview { for _, l := range rd.Preview {
view.Preview = append(view.Preview, l.Dst) view.Preview = append(view.Preview, l.Dst)
@@ -124,6 +153,7 @@ func (s *server) handleApply(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.deps.Reviewer.Apply(r.Context(), id); err != nil { if err := s.deps.Reviewer.Apply(r.Context(), id); err != nil {
s.deps.Logger.Warn("review action failed", "action", "apply", "id", id, "err", err)
redirectReview(w, r, id, err.Error()) redirectReview(w, r, id, err.Error())
return return
} }
@@ -137,6 +167,12 @@ func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
return s.deps.Reviewer.Rerecognize(ctx, id)
})
}
func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) { func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error { s.reviewAction(w, r, func(ctx context.Context, id int64) error {
_ = r.ParseForm() _ = r.ParseForm()
@@ -151,6 +187,32 @@ func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
}) })
} }
func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
_ = r.ParseForm()
candidateID, err := strconv.ParseInt(r.PostForm.Get("candidate_id"), 10, 64)
if err != nil {
return errInvalidCandidate
}
return s.deps.Reviewer.ChooseCandidate(ctx, id, candidateID)
})
}
func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
_ = r.ParseForm()
return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
})
}
func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
return s.deps.Reviewer.ClearProvider(ctx, id)
})
}
var errInvalidCandidate = errors.New("некорректный id кандидата")
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) { func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
@@ -158,6 +220,7 @@ func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.deps.Reviewer.Defer(r.Context(), id); err != nil { if err := s.deps.Reviewer.Defer(r.Context(), id); err != nil {
s.deps.Logger.Warn("review action failed", "action", "defer", "id", id, "err", err)
redirectReview(w, r, id, err.Error()) redirectReview(w, r, id, err.Error())
return return
} }
@@ -171,6 +234,23 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil { if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil {
s.deps.Logger.Warn("review action failed", "action", "undo", "id", id, "err", err)
redirectErr(w, r, err.Error())
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// handleRelink повторно привязывает откатанную задачу: перезапускает
// распознавание, задача пройдёт recognizing → review для подтверждения.
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
redirectErr(w, r, "некорректный id")
return
}
if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil {
s.deps.Logger.Warn("review action failed", "action", "relink", "id", id, "err", err)
redirectErr(w, r, err.Error()) redirectErr(w, r, err.Error())
return return
} }
@@ -186,6 +266,8 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
return return
} }
if err := fn(r.Context(), id); err != nil { if err := fn(r.Context(), id); err != nil {
s.deps.Logger.Warn("review action failed",
"action", r.URL.Path, "id", id, "err", err)
redirectReview(w, r, id, err.Error()) redirectReview(w, r, id, err.Error())
return return
} }
+2
View File
@@ -101,6 +101,8 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
SavePath: s.cfg.SavePath, SavePath: s.cfg.SavePath,
}) })
if addErr != nil { if addErr != nil {
s.log.Warn("ingest: qbittorrent add failed, marking download failed",
"download_id", id, "infohash", info.Infohash, "err", addErr)
// Задача уже в БД — помечаем failed, чтобы worker её не подхватил. // Задача уже в БД — помечаем failed, чтобы worker её не подхватил.
if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, "qbit_add", addErr.Error()); setErr != nil { if setErr := s.store.SetDownloadState(ctx, id, store.StateFailed, "qbit_add", addErr.Error()); setErr != nil {
s.log.Error("ingest: failed to mark download failed after qbit error", s.log.Error("ingest: failed to mark download failed after qbit error",
+93
View File
@@ -0,0 +1,93 @@
// Package jellyfin — минимальный клиент Jellyfin для пересканирования
// медиатеки после успешной раскладки. Единственная задача: дёрнуть скан
// всех библиотек (POST /Library/Refresh), чтобы новые хардлинки быстрее
// появились в проигрывателе. В духе сервиса — без зоопарка вызовов.
package jellyfin
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
const defaultTimeout = 10 * time.Second
// Config — подключение к Jellyfin.
type Config struct {
URL string
APIKey string
Proxy string // опц. HTTP-прокси
Timeout time.Duration
}
// Client — клиент Jellyfin API.
type Client struct {
base string
apiKey string
hc *http.Client
log *slog.Logger
}
// New собирает клиент с опц. прокси. logger nil → slog.Default().
func New(cfg Config, logger *slog.Logger) (*Client, error) {
base, err := url.Parse(strings.TrimRight(cfg.URL, "/"))
if err != nil {
return nil, fmt.Errorf("jellyfin: parse url %q: %w", cfg.URL, err)
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
transport := http.DefaultTransport
if cfg.Proxy != "" {
pu, perr := url.Parse(cfg.Proxy)
if perr != nil {
return nil, fmt.Errorf("jellyfin: parse proxy %q: %w", cfg.Proxy, perr)
}
// Клонируем дефолтный транспорт (dial/TLS-таймауты, keep-alive), а не
// собираем голый — как в metadata-клиенте.
t := http.DefaultTransport.(*http.Transport).Clone()
t.Proxy = http.ProxyURL(pu)
transport = t
}
if logger == nil {
logger = slog.Default()
}
return &Client{
base: base.String(),
apiKey: cfg.APIKey,
hc: &http.Client{Timeout: timeout, Transport: transport},
log: logger,
}, nil
}
// RefreshLibraries запускает скан всех библиотек Jellyfin
// (POST /Library/Refresh). Скан инкрементальный — полный дёшев, поэтому
// точечный скан конкретной папки не делаем (сложнее, не в духе сервиса).
// Ответ при успехе — 204 No Content.
func (c *Client) RefreshLibraries(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/Library/Refresh", nil)
if err != nil {
return fmt.Errorf("jellyfin: build request: %w", err)
}
req.Header.Set("X-Emby-Token", c.apiKey)
start := time.Now()
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("jellyfin: refresh: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("jellyfin: refresh: status %d body %q",
resp.StatusCode, strings.TrimSpace(string(body)))
}
c.log.Info("jellyfin: library refresh triggered", "duration", time.Since(start))
return nil
}
+71
View File
@@ -0,0 +1,71 @@
package jellyfin
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func TestRefreshLibraries_OK(t *testing.T) {
var gotPath, gotToken, gotMethod string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
gotToken = r.Header.Get("X-Emby-Token")
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c, err := New(Config{URL: srv.URL, APIKey: "secret"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := c.RefreshLibraries(context.Background()); err != nil {
t.Fatalf("RefreshLibraries: %v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("method = %q, want POST", gotMethod)
}
if gotPath != "/Library/Refresh" {
t.Errorf("path = %q, want /Library/Refresh", gotPath)
}
if gotToken != "secret" {
t.Errorf("token = %q, want secret", gotToken)
}
}
func TestRefreshLibraries_TrimsTrailingSlash(t *testing.T) {
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
c, err := New(Config{URL: srv.URL + "/", APIKey: "k"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := c.RefreshLibraries(context.Background()); err != nil {
t.Fatalf("RefreshLibraries: %v", err)
}
if gotPath != "/Library/Refresh" {
t.Errorf("path = %q, want /Library/Refresh (без двойного слеша)", gotPath)
}
}
func TestRefreshLibraries_ErrorStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
c, err := New(Config{URL: srv.URL, APIKey: "bad"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := c.RefreshLibraries(context.Background()); err == nil {
t.Fatal("ожидали ошибку на 401, получили nil")
}
}
+102 -17
View File
@@ -15,7 +15,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"syscall" "syscall"
@@ -49,7 +51,7 @@ const (
// PlanFile — один файл к раскладке. // PlanFile — один файл к раскладке.
type PlanFile struct { type PlanFile struct {
Src string // абсолютный путь источника (content dir + относительное имя) Src string // абсолютный путь источника (save_path + относительное имя)
Role Role Role Role
Season *int // для сериала Season *int // для сериала
Episode *int // для сериала Episode *int // для сериала
@@ -86,10 +88,12 @@ type Layouter struct {
movies string movies string
series string series string
dirMode os.FileMode dirMode os.FileMode
log *slog.Logger
} }
// New собирает раскладчик. Корни нормализуются (filepath.Clean). // New собирает раскладчик. Корни нормализуются (filepath.Clean). logger nil →
func New(cfg Config) (*Layouter, error) { // slog.Default().
func New(cfg Config, logger *slog.Logger) (*Layouter, error) {
if cfg.MoviesDir == "" || cfg.SeriesDir == "" { if cfg.MoviesDir == "" || cfg.SeriesDir == "" {
return nil, fmt.Errorf("layout: movies/series dirs required") return nil, fmt.Errorf("layout: movies/series dirs required")
} }
@@ -97,10 +101,14 @@ func New(cfg Config) (*Layouter, error) {
if mode == 0 { if mode == 0 {
mode = 0o755 mode = 0o755
} }
if logger == nil {
logger = slog.Default()
}
return &Layouter{ return &Layouter{
movies: filepath.Clean(cfg.MoviesDir), movies: filepath.Clean(cfg.MoviesDir),
series: filepath.Clean(cfg.SeriesDir), series: filepath.Clean(cfg.SeriesDir),
dirMode: mode, dirMode: mode,
log: logger,
}, nil }, nil
} }
@@ -112,7 +120,7 @@ func (l *Layouter) root(t MediaType) (string, error) {
case Series: case Series:
return l.series, nil return l.series, nil
default: default:
return "", fmt.Errorf("layout: неизвестный тип %q", t) return "", fmt.Errorf("layout: unknown type %q", t)
} }
} }
@@ -152,12 +160,12 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) {
continue // роль не линкуется (extra/sample/ignore) continue // роль не линкуется (extra/sample/ignore)
} }
if !underRoot(root, dst) { if !underRoot(root, dst) {
return nil, fmt.Errorf("layout: цель %q вне библиотеки %q (файл %q)", dst, root, f.Src) return nil, fmt.Errorf("layout: target %q is outside library %q (file %q)", dst, root, f.Src)
} }
links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind}) links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind})
} }
if len(links) == 0 { if len(links) == 0 {
return nil, fmt.Errorf("layout: план не дал ни одной ссылки") return nil, fmt.Errorf("layout: plan produced no links")
} }
return links, nil return links, nil
} }
@@ -180,7 +188,7 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki
return "", "", nil return "", "", nil
} }
if f.Season == nil || f.Episode == nil { if f.Season == nil || f.Episode == nil {
return "", "", fmt.Errorf("layout: файл %q без season/episode", f.Src) return "", "", fmt.Errorf("layout: file %q has no season/episode", f.Src)
} }
episodeEnd := 0 episodeEnd := 0
if f.EpisodeEnd != nil { if f.EpisodeEnd != nil {
@@ -203,7 +211,8 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki
type LinkStatus string type LinkStatus string
const ( const (
StatusLinked LinkStatus = "linked" // ссылка создана StatusLinked LinkStatus = "linked" // хардлинк создан
StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк)
StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно
StatusCollision LinkStatus = "collision" // цель занята другим файлом StatusCollision LinkStatus = "collision" // цель занята другим файлом
) )
@@ -219,7 +228,8 @@ var ErrCollision = errors.New("layout: target collision")
// Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя // Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает // доводит начатое. При коллизии (цель занята чужим файлом) возвращает
// ErrCollision, не перезаписывая. EXDEV (разные ФС) — явная ошибка. // ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС
// не поддерживает link) — фолбэк на копирование файла с предупреждением в лог.
func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) { func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) {
results := make([]Result, 0, len(links)) results := make([]Result, 0, len(links))
for _, ln := range links { for _, ln := range links {
@@ -228,23 +238,28 @@ func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) {
root = l.series root = l.series
} }
if !underRoot(root, ln.Dst) { if !underRoot(root, ln.Dst) {
return results, fmt.Errorf("layout: цель %q вне библиотек", ln.Dst) return results, fmt.Errorf("layout: target %q is outside libraries", ln.Dst)
} }
if err := os.MkdirAll(filepath.Dir(ln.Dst), l.dirMode); err != nil { if err := os.MkdirAll(filepath.Dir(ln.Dst), l.dirMode); err != nil {
return results, fmt.Errorf("layout: mkdir %q: %w", filepath.Dir(ln.Dst), err) return results, fmt.Errorf("layout: mkdir %q: %w", filepath.Dir(ln.Dst), err)
} }
status, err := linkOne(ln.Src, ln.Dst) status, err := l.linkOne(ln.Src, ln.Dst)
if err != nil { if err != nil {
l.log.Error("layout: link failed",
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "err", err)
return results, err return results, err
} }
l.log.Debug("layout: link applied",
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status)
results = append(results, Result{Link: ln, Status: status}) results = append(results, Result{Link: ln, Status: status})
} }
return results, nil return results, nil
} }
// linkOne создаёт одну ссылку, разбирая «уже существует». // linkOne создаёт одну ссылку, разбирая «уже существует» и невозможность
func linkOne(src, dst string) (LinkStatus, error) { // хардлинка (фолбэк на копирование).
func (l *Layouter) linkOne(src, dst string) (LinkStatus, error) {
err := os.Link(src, dst) err := os.Link(src, dst)
if err == nil { if err == nil {
return StatusLinked, nil return StatusLinked, nil
@@ -257,14 +272,82 @@ func linkOne(src, dst string) (LinkStatus, error) {
if same { if same {
return StatusExists, nil // идемпотентно: тот же inode return StatusExists, nil // идемпотентно: тот же inode
} }
return StatusCollision, fmt.Errorf("%w: %q занят другим файлом", ErrCollision, dst) return StatusCollision, fmt.Errorf("%w: %q is occupied by a different file", ErrCollision, dst)
} }
if errors.Is(err, syscall.EXDEV) { if hardlinkUnsupported(err) {
return "", fmt.Errorf("layout: hardlink через границу ФС (%q → %q): %w", src, dst, err) // Хардлинк невозможен (граница ФС или ФС без поддержки link). Не валим
// раскладку — копируем файл и предупреждаем: диск дублируется, но
// задача доходит до конца. dst здесь заведомо отсутствует (иначе был бы
// fs.ErrExist выше).
l.log.Warn("layout: hardlink unsupported, falling back to file copy",
"src", src, "dst", dst, "err", err)
if cerr := copyFile(src, dst); cerr != nil {
return "", fmt.Errorf("layout: copy fallback %q → %q: %w", src, dst, cerr)
}
return StatusCopied, nil
} }
return "", fmt.Errorf("layout: link %q → %q: %w", src, dst, err) return "", fmt.Errorf("layout: link %q → %q: %w", src, dst, err)
} }
// hardlinkUnsupported сообщает, означает ли ошибка os.Link, что хардлинк между
// этими путями в принципе невозможен (а не временный сбой): разные ФС (EXDEV)
// или ФС без поддержки жёстких ссылок (ENOTSUP/EOPNOTSUPP, у части ФС — EPERM).
func hardlinkUnsupported(err error) bool {
return errors.Is(err, syscall.EXDEV) ||
errors.Is(err, syscall.ENOTSUP) ||
errors.Is(err, syscall.EOPNOTSUPP) ||
errors.Is(err, syscall.EPERM)
}
// copyFile копирует src в dst через временный файл в каталоге назначения и
// атомарный rename — так сбой посреди копирования не оставит частичный файл на
// месте dst. Источник не модифицируется. Вызывается, только когда хардлинк
// невозможен (см. linkOne).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open source: %w", err)
}
defer func() { _ = in.Close() }()
info, err := in.Stat()
if err != nil {
return fmt.Errorf("stat source: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".jellybit-copy-*")
if err != nil {
return fmt.Errorf("create temp: %w", err)
}
tmpName := tmp.Name()
cleanup := true
defer func() {
if cleanup {
_ = os.Remove(tmpName)
}
}()
if _, err := io.Copy(tmp, in); err != nil {
_ = tmp.Close()
return fmt.Errorf("copy data: %w", err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
return fmt.Errorf("sync temp: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp: %w", err)
}
if err := os.Chmod(tmpName, info.Mode().Perm()); err != nil {
return fmt.Errorf("chmod temp: %w", err)
}
if err := os.Rename(tmpName, dst); err != nil {
return fmt.Errorf("rename into place: %w", err)
}
cleanup = false
return nil
}
// sameFile сообщает, указывают ли src и dst на один inode. // sameFile сообщает, указывают ли src и dst на один inode.
func sameFile(src, dst string) (bool, error) { func sameFile(src, dst string) (bool, error) {
si, err := os.Stat(src) si, err := os.Stat(src)
@@ -289,15 +372,17 @@ func (l *Layouter) Undo(_ context.Context, links []Link) (int, error) {
root = l.series root = l.series
} }
if !underRoot(root, ln.Dst) { if !underRoot(root, ln.Dst) {
return removed, fmt.Errorf("layout: undo вне библиотеки: %q", ln.Dst) return removed, fmt.Errorf("layout: undo outside library: %q", ln.Dst)
} }
if err := os.Remove(ln.Dst); err != nil { if err := os.Remove(ln.Dst); err != nil {
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
continue continue
} }
l.log.Error("layout: undo remove failed", "dst", ln.Dst, "err", err)
return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err) return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err)
} }
removed++ removed++
l.log.Debug("layout: link removed", "dst", ln.Dst)
pruneEmptyDirs(filepath.Dir(ln.Dst), root) pruneEmptyDirs(filepath.Dir(ln.Dst), root)
} }
return removed, nil return removed, nil
+59 -1
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
"syscall"
"testing" "testing"
) )
@@ -30,7 +31,7 @@ func newFixture(t *testing.T) fixture {
t.Fatal(err) t.Fatal(err)
} }
} }
l, err := New(Config{MoviesDir: movies, SeriesDir: series}) l, err := New(Config{MoviesDir: movies, SeriesDir: series}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -251,6 +252,63 @@ func TestUndo_Idempotent(t *testing.T) {
} }
} }
func TestCopyFile_DuplicatesContentAndKeepsSource(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.mkv")
dst := filepath.Join(dir, "sub", "dst.mkv")
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(src, []byte("payload"), 0o640); err != nil {
t.Fatal(err)
}
if err := copyFile(src, dst); err != nil {
t.Fatalf("copyFile: %v", err)
}
got, err := os.ReadFile(dst)
if err != nil || string(got) != "payload" {
t.Fatalf("dst content = %q, err = %v", got, err)
}
// Источник цел и это отдельный inode (копия, не хардлинк).
si, _ := os.Stat(src)
di, _ := os.Stat(dst)
if os.SameFile(si, di) {
t.Error("dst must be a distinct copy, not a hardlink")
}
if di.Mode().Perm() != 0o640 {
t.Errorf("dst mode = %v, want source mode 0640", di.Mode().Perm())
}
// Временные файлы копирования подчищены.
entries, _ := os.ReadDir(filepath.Dir(dst))
for _, e := range entries {
if len(e.Name()) >= 14 && e.Name()[:14] == ".jellybit-copy" {
t.Errorf("leftover temp file: %s", e.Name())
}
}
}
func TestHardlinkUnsupported(t *testing.T) {
cases := []struct {
err error
want bool
}{
{syscall.EXDEV, true},
{syscall.ENOTSUP, true},
{syscall.EOPNOTSUPP, true},
{syscall.EPERM, true},
{syscall.ENOENT, false},
{os.ErrExist, false},
{errors.New("random"), false},
}
for _, tc := range cases {
if got := hardlinkUnsupported(tc.err); got != tc.want {
t.Errorf("hardlinkUnsupported(%v) = %v, want %v", tc.err, got, tc.want)
}
}
}
func TestUndo_RefusesOutsideLibrary(t *testing.T) { func TestUndo_RefusesOutsideLibrary(t *testing.T) {
f := newFixture(t) f := newFixture(t)
outside := filepath.Join(f.downloads, "victim.mkv") outside := filepath.Join(f.downloads, "victim.mkv")
+1 -1
View File
@@ -32,7 +32,7 @@ func sanitizeComponent(s string) string {
func titleYear(title string, year int) (string, error) { func titleYear(title string, year int) (string, error) {
t := sanitizeComponent(title) t := sanitizeComponent(title)
if t == "" { if t == "" {
return "", fmt.Errorf("layout: пустое название после санитизации (%q)", title) return "", fmt.Errorf("layout: empty title after sanitization (%q)", title)
} }
if year > 0 { if year > 0 {
return fmt.Sprintf("%s (%d)", t, year), nil return fmt.Sprintf("%s (%d)", t, year), nil
+1 -1
View File
@@ -31,7 +31,7 @@ func TestIntegration_OpenAICompat(t *testing.T) {
APIKey: key, APIKey: key,
Model: model, Model: model,
Timeout: 90 * time.Second, Timeout: 90 * time.Second,
}) }, nil)
if err != nil { if err != nil {
t.Fatalf("New: %v", err) t.Fatalf("New: %v", err)
} }
+8 -4
View File
@@ -14,6 +14,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"time" "time"
) )
@@ -74,13 +75,16 @@ type Config struct {
// ErrUnknownType — запрошенный [llm].type не поддерживается. // ErrUnknownType — запрошенный [llm].type не поддерживается.
var ErrUnknownType = errors.New("llm: unknown provider type") var ErrUnknownType = errors.New("llm: unknown provider type")
// New собирает провайдер по дискриминатору cfg.Type. // New собирает провайдер по дискриминатору cfg.Type. logger nil → slog.Default().
func New(cfg Config) (Provider, error) { func New(cfg Config, logger *slog.Logger) (Provider, error) {
if logger == nil {
logger = slog.Default()
}
switch cfg.Type { switch cfg.Type {
case "openai-compat": case "openai-compat":
return newOpenAICompat(cfg) return newOpenAICompat(cfg, logger)
case "": case "":
return nil, fmt.Errorf("%w: %q (укажите [llm].type)", ErrUnknownType, cfg.Type) return nil, fmt.Errorf("%w: %q (set [llm].type)", ErrUnknownType, cfg.Type)
default: default:
return nil, fmt.Errorf("%w: %q", ErrUnknownType, cfg.Type) return nil, fmt.Errorf("%w: %q", ErrUnknownType, cfg.Type)
} }
+6 -6
View File
@@ -20,7 +20,7 @@ func newTestProvider(t *testing.T, baseURL, apiKey string) *openAICompat {
BaseURL: baseURL, BaseURL: baseURL,
APIKey: apiKey, APIKey: apiKey,
Model: "test-model", Model: "test-model",
}) }, nil)
if err != nil { if err != nil {
t.Fatalf("newOpenAICompat: %v", err) t.Fatalf("newOpenAICompat: %v", err)
} }
@@ -215,22 +215,22 @@ func TestComplete_EmptyMessages(t *testing.T) {
} }
func TestNew_UnknownType(t *testing.T) { func TestNew_UnknownType(t *testing.T) {
if _, err := New(Config{Type: "anthropic", Model: "x", BaseURL: "http://x"}); err == nil { if _, err := New(Config{Type: "anthropic", Model: "x", BaseURL: "http://x"}, nil); err == nil {
t.Fatal("want error for unknown type") t.Fatal("want error for unknown type")
} }
if _, err := New(Config{Type: ""}); err == nil { if _, err := New(Config{Type: ""}, nil); err == nil {
t.Fatal("want error for empty type") t.Fatal("want error for empty type")
} }
} }
func TestNew_OpenAICompatValidation(t *testing.T) { func TestNew_OpenAICompatValidation(t *testing.T) {
if _, err := New(Config{Type: "openai-compat", Model: "x"}); err == nil { if _, err := New(Config{Type: "openai-compat", Model: "x"}, nil); err == nil {
t.Fatal("want error for empty base_url") t.Fatal("want error for empty base_url")
} }
if _, err := New(Config{Type: "openai-compat", BaseURL: "http://x"}); err == nil { if _, err := New(Config{Type: "openai-compat", BaseURL: "http://x"}, nil); err == nil {
t.Fatal("want error for empty model") t.Fatal("want error for empty model")
} }
if _, err := New(Config{Type: "openai-compat", BaseURL: "http://x", Model: "m"}); err != nil { if _, err := New(Config{Type: "openai-compat", BaseURL: "http://x", Model: "m"}, nil); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
} }
+22 -1
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -26,10 +27,11 @@ type openAICompat struct {
apiKey string apiKey string
model string model string
retryWait time.Duration // базовая пауза между ретраями (0 в тестах) retryWait time.Duration // базовая пауза между ретраями (0 в тестах)
log *slog.Logger
} }
// newOpenAICompat собирает клиент из конфига. // newOpenAICompat собирает клиент из конфига.
func newOpenAICompat(cfg Config) (*openAICompat, error) { func newOpenAICompat(cfg Config, logger *slog.Logger) (*openAICompat, error) {
if cfg.BaseURL == "" { if cfg.BaseURL == "" {
return nil, fmt.Errorf("llm: empty base_url") return nil, fmt.Errorf("llm: empty base_url")
} }
@@ -51,12 +53,16 @@ func newOpenAICompat(cfg Config) (*openAICompat, error) {
transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)} transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)}
} }
if logger == nil {
logger = slog.Default()
}
return &openAICompat{ return &openAICompat{
endpoint: strings.TrimRight(cfg.BaseURL, "/") + "/chat/completions", endpoint: strings.TrimRight(cfg.BaseURL, "/") + "/chat/completions",
hc: &http.Client{Timeout: timeout, Transport: transport}, hc: &http.Client{Timeout: timeout, Transport: transport},
apiKey: cfg.APIKey, apiKey: cfg.APIKey,
model: cfg.Model, model: cfg.Model,
retryWait: baseRetryWait, retryWait: baseRetryWait,
log: logger,
}, nil }, nil
} }
@@ -119,15 +125,30 @@ func (c *openAICompat) Complete(ctx context.Context, req Request) (Response, err
} }
} }
c.log.Debug("llm: request",
"endpoint", c.endpoint, "model", c.model,
"attempt", attempt, "max_attempts", maxAttempts)
start := time.Now()
resp, retryable, err := c.do(ctx, body) resp, retryable, err := c.do(ctx, body)
if err == nil { if err == nil {
c.log.Debug("llm: response ok",
"model", resp.Model, "attempt", attempt,
"duration", time.Since(start),
"total_tokens", resp.Usage.TotalTokens, "cost", resp.Usage.Cost)
return resp, nil return resp, nil
} }
lastErr = err lastErr = err
if !retryable { if !retryable {
c.log.Error("llm: request failed (non-retryable)",
"model", c.model, "attempt", attempt, "duration", time.Since(start), "err", err)
return Response{}, err return Response{}, err
} }
c.log.Warn("llm: request failed, will retry",
"model", c.model, "attempt", attempt, "max_attempts", maxAttempts,
"duration", time.Since(start), "err", err)
} }
c.log.Error("llm: all attempts exhausted",
"model", c.model, "max_attempts", maxAttempts, "err", lastErr)
return Response{}, fmt.Errorf("llm: exhausted %d attempts: %w", maxAttempts, lastErr) return Response{}, fmt.Errorf("llm: exhausted %d attempts: %w", maxAttempts, lastErr)
} }
+32 -6
View File
@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"time" "time"
@@ -24,7 +25,12 @@ func newHTTPClient(proxy string, timeout time.Duration) (*http.Client, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("metadata: parse proxy %q: %w", proxy, err) return nil, fmt.Errorf("metadata: parse proxy %q: %w", proxy, err)
} }
transport = &http.Transport{Proxy: http.ProxyURL(u)} // Клонируем дефолтный транспорт (dial/TLS-таймауты, keep-alive), а не
// собираем голый — иначе при живом-но-залипшем прокси полагались бы
// только на общий Client.Timeout. Он остаётся верхней границей запроса.
t := http.DefaultTransport.(*http.Transport).Clone()
t.Proxy = http.ProxyURL(u)
transport = t
} }
return &http.Client{Timeout: timeout, Transport: transport}, nil return &http.Client{Timeout: timeout, Transport: transport}, nil
} }
@@ -33,7 +39,7 @@ const maxBody = 4 << 20 // 4 MiB — потолок на тело ответа
// getJSON выполняет GET и декодирует JSON-ответ в out. headers — опц. // getJSON выполняет GET и декодирует JSON-ответ в out. headers — опц.
// дополнительные заголовки (напр. Authorization). // дополнительные заголовки (напр. Authorization).
func getJSON(ctx context.Context, hc *http.Client, rawURL string, headers map[string]string, out any) error { func getJSON(ctx context.Context, hc *http.Client, log *slog.Logger, rawURL string, headers map[string]string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil { if err != nil {
return fmt.Errorf("metadata: build request: %w", err) return fmt.Errorf("metadata: build request: %w", err)
@@ -42,11 +48,11 @@ func getJSON(ctx context.Context, hc *http.Client, rawURL string, headers map[st
for k, v := range headers { for k, v := range headers {
req.Header.Set(k, v) req.Header.Set(k, v)
} }
return doJSON(hc, req, out) return doJSON(hc, log, req, out)
} }
// postJSON выполняет POST с JSON-телом и декодирует ответ. // postJSON выполняет POST с JSON-телом и декодирует ответ.
func postJSON(ctx context.Context, hc *http.Client, rawURL string, body, out any) error { func postJSON(ctx context.Context, hc *http.Client, log *slog.Logger, rawURL string, body, out any) error {
payload, err := json.Marshal(body) payload, err := json.Marshal(body)
if err != nil { if err != nil {
return fmt.Errorf("metadata: marshal body: %w", err) return fmt.Errorf("metadata: marshal body: %w", err)
@@ -57,26 +63,46 @@ func postJSON(ctx context.Context, hc *http.Client, rawURL string, body, out any
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
return doJSON(hc, req, out) return doJSON(hc, log, req, out)
} }
func doJSON(hc *http.Client, req *http.Request, out any) error { // doJSON выполняет запрос и декодирует ответ, логируя исход. В лог идут только
// host и path (без query) — у TMDB api_key передаётся query-параметром, его
// нельзя светить в логах.
func doJSON(hc *http.Client, log *slog.Logger, req *http.Request, out any) error {
if log == nil {
log = slog.Default()
}
start := time.Now()
resp, err := hc.Do(req) resp, err := hc.Do(req)
if err != nil { if err != nil {
log.Warn("metadata: request failed",
"method", req.Method, "host", req.URL.Host, "path", req.URL.Path,
"duration", time.Since(start), "err", err)
return fmt.Errorf("metadata: request: %w", err) return fmt.Errorf("metadata: request: %w", err)
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil { if err != nil {
log.Warn("metadata: read body failed",
"host", req.URL.Host, "path", req.URL.Path, "err", err)
return fmt.Errorf("metadata: read body: %w", err) return fmt.Errorf("metadata: read body: %w", err)
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
log.Warn("metadata: non-ok status",
"method", req.Method, "host", req.URL.Host, "path", req.URL.Path,
"status", resp.StatusCode, "duration", time.Since(start))
return fmt.Errorf("metadata: status %d: %s", resp.StatusCode, snippet(raw)) return fmt.Errorf("metadata: status %d: %s", resp.StatusCode, snippet(raw))
} }
if err := json.Unmarshal(raw, out); err != nil { if err := json.Unmarshal(raw, out); err != nil {
log.Warn("metadata: decode failed",
"host", req.URL.Host, "path", req.URL.Path, "err", err)
return fmt.Errorf("metadata: decode: %w (body: %s)", err, snippet(raw)) return fmt.Errorf("metadata: decode: %w (body: %s)", err, snippet(raw))
} }
log.Debug("metadata: request ok",
"method", req.Method, "host", req.URL.Host, "path", req.URL.Path,
"duration", time.Since(start))
return nil return nil
} }
+40 -1
View File
@@ -9,6 +9,45 @@ import (
"git.vakhrushev.me/av/jellybit/internal/metadata" "git.vakhrushev.me/av/jellybit/internal/metadata"
) )
// TestIntegration_TVMaze бьётся в реальный TVMaze (без ключа). Сетевой, по
// умолчанию пропускается; включается флагом:
//
// JELLYBIT_LIVE=1 go test ./internal/metadata/ -run Integration -v
func TestIntegration_TVMaze(t *testing.T) {
if os.Getenv("JELLYBIT_LIVE") == "" {
t.Skip("set JELLYBIT_LIVE=1 to run network tests")
}
c, err := metadata.NewTVMaze(metadata.TVMazeConfig{Timeout: 20 * time.Second}, nil)
if err != nil {
t.Fatalf("NewTVMaze: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cands, err := c.Search(ctx, metadata.Query{Type: metadata.Series, Title: "Fargo", Year: 2014})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(cands) == 0 {
t.Fatal("ожидался хотя бы один кандидат")
}
top := cands[0]
t.Logf("top: id=%s title=%q year=%d tag=%s/%s",
top.ID, top.Title, top.Year, top.TagProvider, top.TagID)
if top.TagProvider != "tvdb" || top.TagID == "" {
t.Errorf("ожидался TVDB-тег из externals, got %s/%s", top.TagProvider, top.TagID)
}
counts, err := c.SeasonEpisodeCounts(ctx, top.ID)
if err != nil {
t.Fatalf("SeasonEpisodeCounts: %v", err)
}
t.Logf("season counts: %v", counts)
if counts[1] == 0 {
t.Error("ожидались серии в первом сезоне")
}
}
// TestIntegration_TVDB бьётся в реальный TheTVDB v4. По умолчанию // TestIntegration_TVDB бьётся в реальный TheTVDB v4. По умолчанию
// пропускается; включается ключом: // пропускается; включается ключом:
// //
@@ -18,7 +57,7 @@ func TestIntegration_TVDB(t *testing.T) {
if key == "" { if key == "" {
t.Skip("set TVDB_API_KEY to run") t.Skip("set TVDB_API_KEY to run")
} }
c, err := metadata.NewTVDB(metadata.TVDBConfig{APIKey: key, Timeout: 20 * time.Second}) c, err := metadata.NewTVDB(metadata.TVDBConfig{APIKey: key, Timeout: 20 * time.Second}, nil)
if err != nil { if err != nil {
t.Fatalf("NewTVDB: %v", err) t.Fatalf("NewTVDB: %v", err)
} }
+8 -1
View File
@@ -25,12 +25,19 @@ type Query struct {
} }
// Candidate — результат поиска: официальный id и каноническое имя. // Candidate — результат поиска: официальный id и каноническое имя.
//
// ID — нативный id провайдера (по нему запрашиваются SeasonEpisodeCounts).
// TagProvider/TagID — опц. внешний id для имени папки Jellyfin: напр. TVMaze
// ищет без ключа, но отдаёт TVDB/IMDb-id во внешних ссылках, и тег ставим
// привычный ([tvdbid-…]). Пусто → тег берётся из Provider/ID.
type Candidate struct { type Candidate struct {
Provider string // "tmdb" | "tvdb" Provider string // "tmdb" | "tvdb" | "tvmaze"
ID string ID string
Title string Title string
OriginalTitle string OriginalTitle string
Year int Year int
TagProvider string // напр. "tvdb"/"imdb" (опц.)
TagID string
} }
// Provider — одна база метаданных. // Provider — одна база метаданных.
+13 -6
View File
@@ -3,6 +3,7 @@ package metadata
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@@ -25,10 +26,11 @@ type TMDB struct {
apiKey string apiKey string
baseURL string baseURL string
hc *http.Client hc *http.Client
log *slog.Logger
} }
// NewTMDB собирает клиент TMDB. // NewTMDB собирает клиент TMDB. logger nil → slog.Default().
func NewTMDB(cfg TMDBConfig) (*TMDB, error) { func NewTMDB(cfg TMDBConfig, logger *slog.Logger) (*TMDB, error) {
if cfg.APIKey == "" { if cfg.APIKey == "" {
return nil, fmt.Errorf("metadata: tmdb api_key required") return nil, fmt.Errorf("metadata: tmdb api_key required")
} }
@@ -40,7 +42,10 @@ func NewTMDB(cfg TMDBConfig) (*TMDB, error) {
if base == "" { if base == "" {
base = tmdbDefaultBaseURL base = tmdbDefaultBaseURL
} }
return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc}, nil if logger == nil {
logger = slog.Default()
}
return &TMDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil
} }
func (t *TMDB) Name() string { return "tmdb" } func (t *TMDB) Name() string { return "tmdb" }
@@ -73,13 +78,15 @@ func (t *TMDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
params.Set("first_air_date_year", strconv.Itoa(q.Year)) params.Set("first_air_date_year", strconv.Itoa(q.Year))
} }
default: default:
return nil, fmt.Errorf("metadata: tmdb: неизвестный тип %q", q.Type) return nil, fmt.Errorf("metadata: tmdb: unknown type %q", q.Type)
} }
t.log.Debug("tmdb: search", "type", q.Type, "title", q.Title, "year", q.Year)
var resp tmdbSearchResp var resp tmdbSearchResp
if err := getJSON(ctx, t.hc, t.baseURL+path+"?"+params.Encode(), nil, &resp); err != nil { if err := getJSON(ctx, t.hc, t.log, t.baseURL+path+"?"+params.Encode(), nil, &resp); err != nil {
return nil, fmt.Errorf("tmdb search: %w", err) return nil, fmt.Errorf("tmdb search: %w", err)
} }
t.log.Debug("tmdb: search done", "title", q.Title, "results", len(resp.Results))
out := make([]Candidate, 0, len(resp.Results)) out := make([]Candidate, 0, len(resp.Results))
for _, r := range resp.Results { for _, r := range resp.Results {
@@ -109,7 +116,7 @@ type tmdbTVResp struct {
func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) { func (t *TMDB) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
params := url.Values{"api_key": {t.apiKey}} params := url.Values{"api_key": {t.apiKey}}
var resp tmdbTVResp var resp tmdbTVResp
if err := getJSON(ctx, t.hc, t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil { if err := getJSON(ctx, t.hc, t.log, t.baseURL+"/tv/"+url.PathEscape(id)+"?"+params.Encode(), nil, &resp); err != nil {
return nil, fmt.Errorf("tmdb tv %s: %w", id, err) return nil, fmt.Errorf("tmdb tv %s: %w", id, err)
} }
out := make(map[int]int, len(resp.Seasons)) out := make(map[int]int, len(resp.Seasons))
+2 -2
View File
@@ -9,7 +9,7 @@ import (
func newTMDB(t *testing.T, url string) *TMDB { func newTMDB(t *testing.T, url string) *TMDB {
t.Helper() t.Helper()
c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: url}) c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: url}, nil)
if err != nil { if err != nil {
t.Fatalf("NewTMDB: %v", err) t.Fatalf("NewTMDB: %v", err)
} }
@@ -103,7 +103,7 @@ func TestTMDB_ErrorStatus(t *testing.T) {
} }
func TestNewTMDB_RequiresKey(t *testing.T) { func TestNewTMDB_RequiresKey(t *testing.T) {
if _, err := NewTMDB(TMDBConfig{}); err == nil { if _, err := NewTMDB(TMDBConfig{}, nil); err == nil {
t.Fatal("want error without api_key") t.Fatal("want error without api_key")
} }
} }
+19 -4
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@@ -30,13 +31,14 @@ type TVDB struct {
apiKey string apiKey string
baseURL string baseURL string
hc *http.Client hc *http.Client
log *slog.Logger
mu sync.Mutex mu sync.Mutex
token string token string
} }
// NewTVDB собирает клиент TVDB. // NewTVDB собирает клиент TVDB. logger nil → slog.Default().
func NewTVDB(cfg TVDBConfig) (*TVDB, error) { func NewTVDB(cfg TVDBConfig, logger *slog.Logger) (*TVDB, error) {
if cfg.APIKey == "" { if cfg.APIKey == "" {
return nil, fmt.Errorf("metadata: tvdb api_key required") return nil, fmt.Errorf("metadata: tvdb api_key required")
} }
@@ -48,7 +50,10 @@ func NewTVDB(cfg TVDBConfig) (*TVDB, error) {
if base == "" { if base == "" {
base = tvdbDefaultBaseURL base = tvdbDefaultBaseURL
} }
return &TVDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc}, nil if logger == nil {
logger = slog.Default()
}
return &TVDB{apiKey: cfg.APIKey, baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil
} }
func (t *TVDB) Name() string { return "tvdb" } func (t *TVDB) Name() string { return "tvdb" }
@@ -65,7 +70,8 @@ func (t *TVDB) login(ctx context.Context) (string, error) {
Token string `json:"token"` Token string `json:"token"`
} `json:"data"` } `json:"data"`
} }
if err := postJSON(ctx, t.hc, t.baseURL+"/login", t.log.Debug("tvdb: login (fetching bearer token)")
if err := postJSON(ctx, t.hc, t.log, t.baseURL+"/login",
map[string]string{"apikey": t.apiKey}, &resp); err != nil { map[string]string{"apikey": t.apiKey}, &resp); err != nil {
return "", fmt.Errorf("tvdb login: %w", err) return "", fmt.Errorf("tvdb login: %w", err)
} }
@@ -87,6 +93,7 @@ func (t *TVDB) get(ctx context.Context, path string, out any) error {
return err return err
} }
if status == http.StatusUnauthorized { if status == http.StatusUnauthorized {
t.log.Warn("tvdb: token expired, re-login", "path", path)
t.mu.Lock() t.mu.Lock()
t.token = "" // сбрасываем протухший токен t.token = "" // сбрасываем протухший токен
t.mu.Unlock() t.mu.Unlock()
@@ -113,15 +120,21 @@ func (t *TVDB) rawGet(ctx context.Context, path, token string) (int, []byte, err
} }
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
start := time.Now()
resp, err := t.hc.Do(req) resp, err := t.hc.Do(req)
if err != nil { if err != nil {
t.log.Warn("tvdb: request failed",
"host", req.URL.Host, "path", req.URL.Path, "duration", time.Since(start), "err", err)
return 0, nil, fmt.Errorf("tvdb: request: %w", err) return 0, nil, fmt.Errorf("tvdb: request: %w", err)
} }
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil { if err != nil {
t.log.Warn("tvdb: read body failed", "host", req.URL.Host, "path", req.URL.Path, "err", err)
return 0, nil, fmt.Errorf("tvdb: read body: %w", err) return 0, nil, fmt.Errorf("tvdb: read body: %w", err)
} }
t.log.Debug("tvdb: request done",
"host", req.URL.Host, "path", req.URL.Path, "status", resp.StatusCode, "duration", time.Since(start))
return resp.StatusCode, raw, nil return resp.StatusCode, raw, nil
} }
@@ -143,10 +156,12 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
if q.Year > 0 { if q.Year > 0 {
params.Set("year", strconv.Itoa(q.Year)) params.Set("year", strconv.Itoa(q.Year))
} }
t.log.Debug("tvdb: search", "type", q.Type, "title", q.Title, "year", q.Year)
var resp tvdbSearchResp var resp tvdbSearchResp
if err := t.get(ctx, "/search?"+params.Encode(), &resp); err != nil { if err := t.get(ctx, "/search?"+params.Encode(), &resp); err != nil {
return nil, fmt.Errorf("tvdb search: %w", err) return nil, fmt.Errorf("tvdb search: %w", err)
} }
t.log.Debug("tvdb: search done", "title", q.Title, "results", len(resp.Data))
out := make([]Candidate, 0, len(resp.Data)) out := make([]Candidate, 0, len(resp.Data))
for _, r := range resp.Data { for _, r := range resp.Data {
if r.TVDBID == "" { if r.TVDBID == "" {
+2 -2
View File
@@ -52,7 +52,7 @@ func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server {
func newTVDB(t *testing.T, url string) *TVDB { func newTVDB(t *testing.T, url string) *TVDB {
t.Helper() t.Helper()
c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url}) c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url}, nil)
if err != nil { if err != nil {
t.Fatalf("NewTVDB: %v", err) t.Fatalf("NewTVDB: %v", err)
} }
@@ -126,7 +126,7 @@ func TestTVDB_ReloginOn401(t *testing.T) {
} }
func TestNewTVDB_RequiresKey(t *testing.T) { func TestNewTVDB_RequiresKey(t *testing.T) {
if _, err := NewTVDB(TVDBConfig{}); err == nil { if _, err := NewTVDB(TVDBConfig{}, nil); err == nil {
t.Fatal("want error without api_key") t.Fatal("want error without api_key")
} }
} }
+115
View File
@@ -0,0 +1,115 @@
package metadata
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const tvmazeDefaultBaseURL = "https://api.tvmaze.com"
// TVMazeConfig — настройки клиента TVMaze.
type TVMazeConfig struct {
Proxy string
Timeout time.Duration
BaseURL string // пусто → api.tvmaze.com; задаётся в тестах
}
// TVMaze — клиент TVMaze. Открытое API без ключа (лимит ~20 запросов/10с).
// Покрывает только сериалы; для фильмов поиск возвращает пусто. В externals
// отдаёт TVDB/IMDb-id, который используем как тег папки Jellyfin.
type TVMaze struct {
baseURL string
hc *http.Client
log *slog.Logger
}
// NewTVMaze собирает клиент TVMaze (ключ не нужен). logger nil → slog.Default().
func NewTVMaze(cfg TVMazeConfig, logger *slog.Logger) (*TVMaze, error) {
hc, err := newHTTPClient(cfg.Proxy, cfg.Timeout)
if err != nil {
return nil, err
}
base := cfg.BaseURL
if base == "" {
base = tvmazeDefaultBaseURL
}
if logger == nil {
logger = slog.Default()
}
return &TVMaze{baseURL: strings.TrimRight(base, "/"), hc: hc, log: logger}, nil
}
func (t *TVMaze) Name() string { return "tvmaze" }
type tvmazeShow struct {
ID int `json:"id"`
Name string `json:"name"`
Premiered string `json:"premiered"`
Externals struct {
TheTVDB int `json:"thetvdb"`
IMDb string `json:"imdb"`
} `json:"externals"`
}
// Search ищет сериал по названию. Фильмы TVMaze не покрывает — для них
// возвращает пусто. Год не сужаем в запросе (TVMaze не фильтрует по году),
// отбор по году делает вызывающий.
func (t *TVMaze) Search(ctx context.Context, q Query) ([]Candidate, error) {
if q.Type != Series {
return nil, nil
}
var resp []struct {
Show tvmazeShow `json:"show"`
}
rawURL := t.baseURL + "/search/shows?q=" + url.QueryEscape(q.Title)
t.log.Debug("tvmaze: search", "title", q.Title)
if err := getJSON(ctx, t.hc, t.log, rawURL, nil, &resp); err != nil {
return nil, fmt.Errorf("tvmaze search: %w", err)
}
t.log.Debug("tvmaze: search done", "title", q.Title, "results", len(resp))
out := make([]Candidate, 0, len(resp))
for _, r := range resp {
s := r.Show
c := Candidate{
Provider: "tvmaze",
ID: strconv.Itoa(s.ID),
Title: s.Name,
Year: yearOf(s.Premiered),
}
// Тег папки — привычный TVDB-id, если есть; иначе IMDb.
switch {
case s.Externals.TheTVDB != 0:
c.TagProvider, c.TagID = "tvdb", strconv.Itoa(s.Externals.TheTVDB)
case s.Externals.IMDb != "":
c.TagProvider, c.TagID = "imdb", s.Externals.IMDb
}
out = append(out, c)
}
return out, nil
}
type tvmazeEpisode struct {
Season int `json:"season"`
Number int `json:"number"`
}
// SeasonEpisodeCounts считает число серий по сезонам (нативный id TVMaze).
func (t *TVMaze) SeasonEpisodeCounts(ctx context.Context, id string) (map[int]int, error) {
var eps []tvmazeEpisode
rawURL := t.baseURL + "/shows/" + url.PathEscape(id) + "/episodes"
if err := getJSON(ctx, t.hc, t.log, rawURL, nil, &eps); err != nil {
return nil, fmt.Errorf("tvmaze episodes %s: %w", id, err)
}
out := map[int]int{}
for _, e := range eps {
out[e.Season]++
}
return out, nil
}
+86
View File
@@ -0,0 +1,86 @@
package metadata
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
func newTVMaze(t *testing.T, url string) *TVMaze {
t.Helper()
c, err := NewTVMaze(TVMazeConfig{BaseURL: url}, nil)
if err != nil {
t.Fatalf("NewTVMaze: %v", err)
}
return c
}
func TestTVMaze_SearchSeries(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/shows" || r.URL.Query().Get("q") != "Fargo" {
t.Errorf("request = %s?%s", r.URL.Path, r.URL.RawQuery)
}
_, _ = w.Write([]byte(`[
{"score":0.9,"show":{"id":1,"name":"Fargo","premiered":"2014-04-15",
"externals":{"thetvdb":269613,"imdb":"tt2802850"}}},
{"score":0.1,"show":{"id":2,"name":"Other","premiered":"2010-01-01",
"externals":{"thetvdb":0,"imdb":"tt999"}}}
]`))
}))
defer srv.Close()
got, err := newTVMaze(t, srv.URL).Search(context.Background(), Query{Type: Series, Title: "Fargo", Year: 2014})
if err != nil {
t.Fatalf("Search: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d candidates", len(got))
}
c := got[0]
if c.Provider != "tvmaze" || c.ID != "1" || c.Title != "Fargo" || c.Year != 2014 {
t.Errorf("candidate = %+v", c)
}
// TVDB-id из externals → тег папки.
if c.TagProvider != "tvdb" || c.TagID != "269613" {
t.Errorf("tag = %s/%s, want tvdb/269613", c.TagProvider, c.TagID)
}
// Без thetvdb → фолбэк на imdb.
if got[1].TagProvider != "imdb" || got[1].TagID != "tt999" {
t.Errorf("fallback tag = %s/%s", got[1].TagProvider, got[1].TagID)
}
}
func TestTVMaze_SearchMovieEmpty(t *testing.T) {
// Для фильмов TVMaze не вызывается вовсе.
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
t.Error("movie search must not hit the network")
}))
defer srv.Close()
got, err := newTVMaze(t, srv.URL).Search(context.Background(), Query{Type: Movie, Title: "X"})
if err != nil || got != nil {
t.Errorf("movie search = %v, %v; want nil, nil", got, err)
}
}
func TestTVMaze_SeasonEpisodeCounts(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/shows/1/episodes" {
t.Errorf("path = %q", r.URL.Path)
}
_, _ = w.Write([]byte(`[
{"season":1,"number":1},{"season":1,"number":2},
{"season":2,"number":1}
]`))
}))
defer srv.Close()
counts, err := newTVMaze(t, srv.URL).SeasonEpisodeCounts(context.Background(), "1")
if err != nil {
t.Fatalf("SeasonEpisodeCounts: %v", err)
}
if counts[1] != 2 || counts[2] != 1 {
t.Errorf("counts = %v", counts)
}
}
+28 -6
View File
@@ -12,6 +12,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
@@ -36,6 +37,7 @@ type Client struct {
hc *http.Client hc *http.Client
user string user string
pass string pass string
log *slog.Logger
mu sync.Mutex // сериализует логин mu sync.Mutex // сериализует логин
} }
@@ -47,6 +49,7 @@ type Torrent struct {
SavePath string `json:"save_path"` SavePath string `json:"save_path"`
ContentPath string `json:"content_path"` ContentPath string `json:"content_path"`
Category string `json:"category"` Category string `json:"category"`
Tags string `json:"tags"` // через запятую
Progress float64 `json:"progress"` Progress float64 `json:"progress"`
AmountLeft int64 `json:"amount_left"` AmountLeft int64 `json:"amount_left"`
AddedOn int64 `json:"added_on"` AddedOn int64 `json:"added_on"`
@@ -54,8 +57,11 @@ type Torrent struct {
InfohashV2 string `json:"infohash_v2"` InfohashV2 string `json:"infohash_v2"`
} }
// File — элемент /torrents/files: путь файла относительно content_path и // File — элемент /torrents/files: путь файла относительно save_path
// его размер. // (включая корневую папку торрента для многофайловых раздач) и его размер.
// Абсолютный путь на диске = filepath.Join(save_path, Name) — НЕ content_path:
// для многофайловой раздачи это удвоило бы корневую папку, для однофайловой
// дало бы путь под самим файлом.
type File struct { type File struct {
Name string `json:"name"` Name string `json:"name"`
Size int64 `json:"size"` Size int64 `json:"size"`
@@ -70,8 +76,8 @@ type AddRequest struct {
Paused bool Paused bool
} }
// New создаёт клиент с собственным cookie-jar. // New создаёт клиент с собственным cookie-jar. logger nil → slog.Default().
func New(cfg Config) (*Client, error) { func New(cfg Config, logger *slog.Logger) (*Client, error) {
base, err := url.Parse(strings.TrimRight(cfg.URL, "/")) base, err := url.Parse(strings.TrimRight(cfg.URL, "/"))
if err != nil { if err != nil {
return nil, fmt.Errorf("parse qbittorrent url %q: %w", cfg.URL, err) return nil, fmt.Errorf("parse qbittorrent url %q: %w", cfg.URL, err)
@@ -84,11 +90,15 @@ func New(cfg Config) (*Client, error) {
if timeout == 0 { if timeout == 0 {
timeout = 30 * time.Second timeout = 30 * time.Second
} }
if logger == nil {
logger = slog.Default()
}
return &Client{ return &Client{
base: base, base: base,
hc: &http.Client{Jar: jar, Timeout: timeout}, hc: &http.Client{Jar: jar, Timeout: timeout},
user: cfg.Username, user: cfg.Username,
pass: cfg.Password, pass: cfg.Password,
log: logger,
}, nil }, nil
} }
@@ -115,9 +125,12 @@ func (c *Client) login(ctx context.Context) error {
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "Ok." { if resp.StatusCode != http.StatusOK || strings.TrimSpace(string(body)) != "Ok." {
c.log.Error("qbittorrent: login failed",
"status", resp.StatusCode, "body", strings.TrimSpace(string(body)))
return fmt.Errorf("qbittorrent login failed: status %d body %q", return fmt.Errorf("qbittorrent login failed: status %d body %q",
resp.StatusCode, strings.TrimSpace(string(body))) resp.StatusCode, strings.TrimSpace(string(body)))
} }
c.log.Debug("qbittorrent: login ok", "user", c.user)
return nil return nil
} }
@@ -134,6 +147,7 @@ func (c *Client) do(ctx context.Context, build func() (*http.Request, error)) (*
} }
if resp.StatusCode == http.StatusForbidden { if resp.StatusCode == http.StatusForbidden {
_ = resp.Body.Close() _ = resp.Body.Close()
c.log.Debug("qbittorrent: session expired (403), re-login")
if err := c.login(ctx); err != nil { if err := c.login(ctx); err != nil {
return nil, err return nil, err
} }
@@ -195,8 +209,13 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
resp.StatusCode, strings.TrimSpace(string(body))) resp.StatusCode, strings.TrimSpace(string(body)))
} }
if strings.TrimSpace(string(body)) == "Fails." { if strings.TrimSpace(string(body)) == "Fails." {
c.log.Error("qbittorrent: add rejected",
"category", ar.Category, "urls", len(ar.URLs), "torrents", len(ar.Torrents))
return fmt.Errorf("qbittorrent add: rejected (Fails.)") return fmt.Errorf("qbittorrent add: rejected (Fails.)")
} }
c.log.Info("qbittorrent: torrent added",
"category", ar.Category, "save_path", ar.SavePath,
"urls", len(ar.URLs), "torrents", len(ar.Torrents), "paused", ar.Paused)
return nil return nil
} }
@@ -222,11 +241,13 @@ func (c *Client) Torrents(ctx context.Context, category string) ([]Torrent, erro
if err := json.NewDecoder(resp.Body).Decode(&ts); err != nil { if err := json.NewDecoder(resp.Body).Decode(&ts); err != nil {
return nil, fmt.Errorf("decode qbittorrent info: %w", err) return nil, fmt.Errorf("decode qbittorrent info: %w", err)
} }
c.log.Debug("qbittorrent: torrents fetched", "category", category, "count", len(ts))
return ts, nil return ts, nil
} }
// Files возвращает список файлов торрента (имена относительно content_path и // Files возвращает список файлов торрента (имена относительно save_path,
// размеры). Нужен распознаванию как один из сигналов. // включая корневую папку для многофайловых раздач, и размеры). Нужен
// распознаванию как один из сигналов; абсолютный путь — join(save_path, Name).
func (c *Client) Files(ctx context.Context, hash string) ([]File, error) { func (c *Client) Files(ctx context.Context, hash string) ([]File, error) {
resp, err := c.do(ctx, func() (*http.Request, error) { resp, err := c.do(ctx, func() (*http.Request, error) {
u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash)) u := c.endpoint("/api/v2/torrents/files?hash=" + url.QueryEscape(hash))
@@ -245,5 +266,6 @@ func (c *Client) Files(ctx context.Context, hash string) ([]File, error) {
if err := json.NewDecoder(resp.Body).Decode(&fs); err != nil { if err := json.NewDecoder(resp.Body).Decode(&fs); err != nil {
return nil, fmt.Errorf("decode qbittorrent files: %w", err) return nil, fmt.Errorf("decode qbittorrent files: %w", err)
} }
c.log.Debug("qbittorrent: files fetched", "hash", hash, "count", len(fs))
return fs, nil return fs, nil
} }
+2 -2
View File
@@ -62,7 +62,7 @@ func fakeQBittorrent(t *testing.T, info string) *httptest.Server {
func newClient(t *testing.T, url string) *Client { func newClient(t *testing.T, url string) *Client {
t.Helper() t.Helper()
c, err := New(Config{URL: url, Username: "admin", Password: "secret"}) c, err := New(Config{URL: url, Username: "admin", Password: "secret"}, nil)
if err != nil { if err != nil {
t.Fatalf("New: %v", err) t.Fatalf("New: %v", err)
} }
@@ -107,7 +107,7 @@ func TestTorrents(t *testing.T) {
func TestLoginFailure(t *testing.T) { func TestLoginFailure(t *testing.T) {
srv := fakeQBittorrent(t, "[]") srv := fakeQBittorrent(t, "[]")
c, err := New(Config{URL: srv.URL, Username: "admin", Password: "wrong"}) c, err := New(Config{URL: srv.URL, Username: "admin", Password: "wrong"}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+1 -1
View File
@@ -38,7 +38,7 @@ func TestIntegration_RecognizeSeries(t *testing.T) {
provider, err := llm.New(llm.Config{ provider, err := llm.New(llm.Config{
Type: "openai-compat", BaseURL: base, APIKey: key, Model: model, Type: "openai-compat", BaseURL: base, APIKey: key, Model: model,
Timeout: 90 * time.Second, Timeout: 90 * time.Second,
}) }, nil)
if err != nil { if err != nil {
t.Fatalf("llm.New: %v", err) t.Fatalf("llm.New: %v", err)
} }
+58 -15
View File
@@ -8,14 +8,17 @@ import (
"git.vakhrushev.me/av/jellybit/internal/metadata" "git.vakhrushev.me/av/jellybit/internal/metadata"
) )
// matchMetadata сверяет план с включёнными базами: ищет по названию+году и, // maxCandidates — потолок на число сохраняемых кандидатов для ручного выбора.
// если ровно один кандидат уверенно совпадает (название и год), возвращает const maxCandidates = 8
// матч с официальным id и каноническим именем. Несколько кандидатов или их
// отсутствие → nil (тогда авто-раскладки не будет). Ошибки провайдера не // matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
// валят распознавание — просто нет матча. // сильный матч — ровно один кандидат с совпадением названия и года (для него
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match { // тянем число серий и используем для авто), либо nil; (б) список кандидатов
// из всех провайдеров (топ-N, дедуп) — чтобы человек мог выбрать в review,
// когда сильного матча нет. Ошибки провайдера не валят распознавание.
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
if len(r.providers) == 0 { if len(r.providers) == 0 {
return nil return nil, nil
} }
mt := metadata.Movie mt := metadata.Movie
if plan.Type == MediaSeries { if plan.Type == MediaSeries {
@@ -28,29 +31,69 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
} }
matchTitles := normSet(plan.Title, plan.OriginalTitle) matchTitles := normSet(plan.Title, plan.OriginalTitle)
var match *Match
var candidates []metadata.Candidate
seen := map[string]bool{}
for _, p := range r.providers { for _, p := range r.providers {
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year}) cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
if err != nil { if err != nil {
r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err) r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err)
continue continue
} }
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
for _, c := range cands {
key := c.Provider + ":" + c.ID
if seen[key] || len(candidates) >= maxCandidates {
continue
}
seen[key] = true
candidates = append(candidates, c)
}
// Единичный сильный матч ищем у первого подходящего провайдера.
if match != nil {
continue
}
strong := strongMatches(cands, plan.Year, matchTitles) strong := strongMatches(cands, plan.Year, matchTitles)
if len(strong) != 1 { if len(strong) != 1 {
continue continue
} }
c := strong[0] match = r.buildMatch(ctx, p, strong[0], mt)
match := &Match{Provider: c.Provider, ProviderID: c.ID, Title: c.Title, Year: c.Year} }
return match, candidates
}
// buildMatch тянет число серий (по нативному id) и собирает Match с
// тег-предпочтительным провенансом.
func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *Match {
var counts map[int]int
if mt == metadata.Series { if mt == metadata.Series {
if counts, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil { if got, err := p.SeasonEpisodeCounts(ctx, c.ID); err == nil {
match.SeasonEpisodeCounts = counts counts = got
} else { } else {
r.log.Warn("recognize: episode counts failed", r.log.Warn("recognize: episode counts failed", "provider", p.Name(), "id", c.ID, "err", err)
"provider", p.Name(), "id", c.ID, "err", cerr)
} }
} }
return match prov, pid := CandidateTag(c)
return &Match{
Provider: prov,
ProviderID: pid,
Title: c.Title,
Year: c.Year,
SeasonEpisodeCounts: counts,
} }
return nil }
// CandidateTag — провайдер и id для тега папки Jellyfin: внешний (из
// TagProvider/TagID, напр. TVMaze → tvdb/imdb), если есть, иначе сам провайдер
// поиска. Используется и в матче, и при сохранении кандидатов.
func CandidateTag(c metadata.Candidate) (provider, id string) {
if c.TagProvider != "" {
return c.TagProvider, c.TagID
}
return c.Provider, c.ID
} }
// strongMatches оставляет кандидатов, чьё название совпадает с одним из // strongMatches оставляет кандидатов, чьё название совпадает с одним из
+75 -7
View File
@@ -44,7 +44,7 @@ func TestMatchMetadata_SingleStrong(t *testing.T) {
{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003}, {Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
}} }}
r := recognizerWith(p) r := recognizerWith(p)
m := r.matchMetadata(context.Background(), m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999}) Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
if m == nil { if m == nil {
t.Fatal("expected match") t.Fatal("expected match")
@@ -61,16 +61,60 @@ func TestMatchMetadata_AmbiguousNoMatch(t *testing.T) {
{ID: "2", Title: "Fargo", Year: 2014}, {ID: "2", Title: "Fargo", Year: 2014},
}} }}
r := recognizerWith(p) r := recognizerWith(p)
if m := r.matchMetadata(context.Background(), if m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil { Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil {
t.Errorf("ambiguous must not match, got %+v", m) t.Errorf("ambiguous must not match, got %+v", m)
} }
} }
func TestMatchMetadata_ReturnsCandidates(t *testing.T) {
// Нет сильного матча (разные названия), но кандидаты собраны для выбора.
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
{Provider: "tvmaze", ID: "2", Title: "Fargo Idaho", Year: 2010},
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014}, // дубль по id
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Совсем другое", Year: 2014})
if m != nil {
t.Errorf("strong match не ожидался: %+v", m)
}
if len(cands) != 2 { // дубль отброшен
t.Fatalf("candidates = %d, want 2: %+v", len(cands), cands)
}
// CandidateTag даёт внешний TVDB-id для первого.
prov, id := CandidateTag(cands[0])
if prov != "tvdb" || id != "269613" {
t.Errorf("tag = %s/%s", prov, id)
}
}
func TestRecognize_PopulatesCandidates(t *testing.T) {
in := Input{Name: "Show.S01", Files: []File{{Path: "e1.mkv", Size: 1}}}
resp := `{"type":"series","title":"Show","year":2020,"confidence":0.9,
"files":[{"src":"e1.mkv","role":"episode","season":1,"episode":1}]}`
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
{Provider: "tvmaze", ID: "1", Title: "Show One", Year: 2020},
{Provider: "tvmaze", ID: "2", Title: "Show Two", Year: 2019},
}}
r := New(&fakeLLM{responses: []string{resp}}, []metadata.Provider{p}, Config{}, testLogger())
res, err := r.Recognize(context.Background(), in)
if err != nil {
t.Fatalf("Recognize: %v", err)
}
if res.Match != nil {
t.Errorf("strong match не ожидался")
}
if len(res.Candidates) != 2 {
t.Errorf("Result.Candidates = %d, want 2", len(res.Candidates))
}
}
func TestMatchMetadata_YearMismatch(t *testing.T) { func TestMatchMetadata_YearMismatch(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}} p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
r := recognizerWith(p) r := recognizerWith(p)
if m := r.matchMetadata(context.Background(), if m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil { Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil {
t.Errorf("year mismatch must not match, got %+v", m) t.Errorf("year mismatch must not match, got %+v", m)
} }
@@ -81,20 +125,44 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) {
{ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994}, {ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994},
}} }}
r := recognizerWith(p) r := recognizerWith(p)
m := r.matchMetadata(context.Background(), m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Léon", Year: 1994}) Plan{Type: MediaMovie, Title: "Léon", Year: 1994})
if m == nil || m.ProviderID != "1" { if m == nil || m.ProviderID != "1" {
t.Errorf("should match by original title, got %+v", m) t.Errorf("should match by original title, got %+v", m)
} }
} }
func TestMatchMetadata_TagFromExternal(t *testing.T) {
// TVMaze-стиль: нативный id для счёта серий, внешний TVDB-id для тега.
p := &fakeProvider{
name: "tvmaze",
candidates: []metadata.Candidate{
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
},
counts: map[int]int{1: 10},
}
r := recognizerWith(p)
m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
if m == nil {
t.Fatal("expected match")
}
// Провенанс/тег — внешний TVDB-id, а не нативный tvmaze.
if m.Provider != "tvdb" || m.ProviderID != "269613" {
t.Errorf("match provider = %s/%s, want tvdb/269613", m.Provider, m.ProviderID)
}
if m.SeasonEpisodeCounts[1] != 10 {
t.Errorf("counts not fetched by native id: %+v", m.SeasonEpisodeCounts)
}
}
func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) { func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
p := &fakeProvider{ p := &fakeProvider{
candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}}, candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}},
counts: map[int]int{1: 10, 2: 10}, counts: map[int]int{1: 10, 2: 10},
} }
r := recognizerWith(p) r := recognizerWith(p)
m := r.matchMetadata(context.Background(), m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}) Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
if m == nil || m.SeasonEpisodeCounts[1] != 10 { if m == nil || m.SeasonEpisodeCounts[1] != 10 {
t.Errorf("counts not fetched: %+v", m) t.Errorf("counts not fetched: %+v", m)
@@ -104,7 +172,7 @@ func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) { func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
p := &fakeProvider{searchErr: errors.New("upstream down")} p := &fakeProvider{searchErr: errors.New("upstream down")}
r := recognizerWith(p) r := recognizerWith(p)
if m := r.matchMetadata(context.Background(), if m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil { Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil {
t.Errorf("provider error must yield no match, got %+v", m) t.Errorf("provider error must yield no match, got %+v", m)
} }
@@ -112,7 +180,7 @@ func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
func TestMatchMetadata_Disabled(t *testing.T) { func TestMatchMetadata_Disabled(t *testing.T) {
r := recognizerWith(nil) r := recognizerWith(nil)
if m := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil { if m, _ := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil {
t.Errorf("no providers → no match, got %+v", m) t.Errorf("no providers → no match, got %+v", m)
} }
} }
+9 -7
View File
@@ -36,7 +36,7 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
"provider_hint": "строка для поиска в базе (НЕ id)", "provider_hint": "строка для поиска в базе (НЕ id)",
"files": [ "files": [
{ {
"src": "путь файла РОВНО как в списке ниже", "src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки",
"role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore", "role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore",
"season": число или null, "season": число или null,
"episode": число или null "episode": число или null
@@ -50,7 +50,8 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore". - "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
- Для сериала каждой серии отдельный файл с role "episode" и заполненными season и episode. - Для сериала каждой серии отдельный файл с role "episode" и заполненными season и episode.
- Для фильма ровно один основной видеофайл role "main". - Для фильма ровно один основной видеофайл role "main".
- Поле src копируй ДОСЛОВНО из списка файлов; не выдумывай и не нормализуй пути. - Поле src это путь файла из списка, скопированный дословно, но БЕЗ размера
«()» в конце строки; не выдумывай и не нормализуй пути.
- Внешние субтитры role "subtitle".` - Внешние субтитры role "subtitle".`
const systemPrompt = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента, const systemPrompt = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента,
@@ -133,14 +134,15 @@ func writeFileList(b *strings.Builder, files []File, maxFiles int) {
} }
b.WriteString("Файлы (") b.WriteString("Файлы (")
b.WriteString(strconv.Itoa(n)) b.WriteString(strconv.Itoa(n))
b.WriteString(", поле src — это точные пути отсюда):\n") b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ")
b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n")
for i := 0; i < shown; i++ { for i := 0; i < shown; i++ {
b.WriteString(strconv.Itoa(i + 1)) b.WriteString(strconv.Itoa(i + 1))
b.WriteString(". [") b.WriteString(". ")
b.WriteString(humanSize(files[i].Size))
b.WriteString("] ")
b.WriteString(files[i].Path) b.WriteString(files[i].Path)
b.WriteByte('\n') b.WriteString(" (")
b.WriteString(humanSize(files[i].Size))
b.WriteString(")\n")
} }
if shown < n { if shown < n {
b.WriteString("… и ещё ") b.WriteString("… и ещё ")
+11 -7
View File
@@ -54,7 +54,7 @@ func (r FileRole) valid() bool {
} }
} }
// File — входной файл торрента (путь относительно content_path и размер). // File — входной файл торрента (путь относительно save_path и размер).
type File struct { type File struct {
Path string Path string
Size int64 Size int64
@@ -118,9 +118,10 @@ type Result struct {
Plan Plan Plan Plan
PreParse PreParse PreParse PreParse
Decision Decision Decision Decision
Match *Match // подтверждённый матч в базе (nil — нет) Match *Match // подтверждённый единичный матч (nil — нет)
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора) Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm) Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи)
Raw string // сырой ответ LLM последней попытки
} }
// LLM — нужная recognize часть провайдера. // LLM — нужная recognize часть провайдера.
@@ -234,8 +235,9 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
} }
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год // Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
// в плане заменяем на каноничные. // в плане заменяем на каноничные. Кандидаты копим для ручного выбора в
match := r.matchMetadata(ctx, plan) // review, когда единичного сильного матча нет.
match, candidates := r.matchMetadata(ctx, plan)
if match != nil { if match != nil {
plan.Title = match.Title plan.Title = match.Title
if match.Year != 0 { if match.Year != 0 {
@@ -247,12 +249,14 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
r.log.Info("recognize: done", r.log.Info("recognize: done",
"type", plan.Type, "title", plan.Title, "year", plan.Year, "type", plan.Type, "title", plan.Title, "year", plan.Year,
"files", len(plan.Files), "attempts", attempts, "files", len(plan.Files), "attempts", attempts,
"matched", match != nil, "auto", dec.Auto, "reasons", len(dec.Reasons)) "matched", match != nil, "candidates", len(candidates),
"auto", dec.Auto, "reasons", len(dec.Reasons))
return Result{ return Result{
Plan: plan, Plan: plan,
PreParse: pre, PreParse: pre,
Decision: dec, Decision: dec,
Match: match, Match: match,
Candidates: candidates,
Attempts: attempts, Attempts: attempts,
Raw: raw, Raw: raw,
}, nil }, nil
+10 -10
View File
@@ -15,7 +15,7 @@ import (
func parsePlan(raw string, in Input) (Plan, error) { func parsePlan(raw string, in Input) (Plan, error) {
jsonStr, err := llm.ExtractJSONObject(raw) jsonStr, err := llm.ExtractJSONObject(raw)
if err != nil { if err != nil {
return Plan{}, fmt.Errorf("в ответе нет JSON-объекта") return Plan{}, fmt.Errorf("no JSON object in response")
} }
var p Plan var p Plan
@@ -25,7 +25,7 @@ func parsePlan(raw string, in Input) (Plan, error) {
// Повторяем без строгого режима: лишние поля — не повод падать, // Повторяем без строгого режима: лишние поля — не повод падать,
// но если и так не разобралось — это ошибка схемы. // но если и так не разобралось — это ошибка схемы.
if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil { if err2 := json.Unmarshal([]byte(jsonStr), &p); err2 != nil {
return Plan{}, fmt.Errorf("JSON не разобран: %v", err2) return Plan{}, fmt.Errorf("JSON not parsed: %v", err2)
} }
} }
@@ -42,15 +42,15 @@ func validateSchema(p *Plan, in Input) error {
switch p.Type { switch p.Type {
case MediaMovie, MediaSeries: case MediaMovie, MediaSeries:
case "": case "":
return fmt.Errorf("поле type пустое (ожидалось movie или series)") return fmt.Errorf("field type is empty (expected movie or series)")
default: default:
return fmt.Errorf("неизвестный type %q", p.Type) return fmt.Errorf("unknown type %q", p.Type)
} }
if strings.TrimSpace(p.Title) == "" { if strings.TrimSpace(p.Title) == "" {
return fmt.Errorf("поле title пустое") return fmt.Errorf("field title is empty")
} }
if len(p.Files) == 0 { if len(p.Files) == 0 {
return fmt.Errorf("список files пуст") return fmt.Errorf("files list is empty")
} }
known := make(map[string]bool, len(in.Files)) known := make(map[string]bool, len(in.Files))
@@ -61,16 +61,16 @@ func validateSchema(p *Plan, in Input) error {
for i := range p.Files { for i := range p.Files {
pf := &p.Files[i] pf := &p.Files[i]
if !pf.Role.valid() { if !pf.Role.valid() {
return fmt.Errorf("файл %q: неизвестная role %q", pf.Src, pf.Role) return fmt.Errorf("file %q: unknown role %q", pf.Src, pf.Role)
} }
if strings.TrimSpace(pf.Src) == "" { if strings.TrimSpace(pf.Src) == "" {
return fmt.Errorf("файл с пустым src") return fmt.Errorf("file with empty src")
} }
if !known[pf.Src] { if !known[pf.Src] {
return fmt.Errorf("src %q не найден среди файлов торрента", pf.Src) return fmt.Errorf("src %q not found among torrent files", pf.Src)
} }
if pf.Role == RoleEpisode && pf.Episode == nil { if pf.Role == RoleEpisode && pf.Episode == nil {
return fmt.Errorf("серия %q без номера episode", pf.Src) return fmt.Errorf("episode %q has no episode number", pf.Src)
} }
} }
return nil return nil
+8 -8
View File
@@ -37,14 +37,14 @@ func TestValidateSchema_Errors(t *testing.T) {
p Plan p Plan
want string want string
}{ }{
{"empty type", Plan{Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "type пустое"}, {"empty type", Plan{Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "type is empty"},
{"bad type", Plan{Type: "show", Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "неизвестный type"}, {"bad type", Plan{Type: "show", Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "unknown type"},
{"empty title", Plan{Type: MediaMovie, Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "title пустое"}, {"empty title", Plan{Type: MediaMovie, Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "title is empty"},
{"no files", Plan{Type: MediaMovie, Title: "x"}, "files пуст"}, {"no files", Plan{Type: MediaMovie, Title: "x"}, "files list is empty"},
{"bad role", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: "boss"}}}, "неизвестная role"}, {"bad role", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: "boss"}}}, "unknown role"},
{"empty src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "пустым src"}, {"empty src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "empty src"},
{"unknown src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "не найден"}, {"unknown src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "not found among torrent files"},
{"episode no num", Plan{Type: MediaSeries, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleEpisode, Season: intp(1)}}}, "без номера episode"}, {"episode no num", Plan{Type: MediaSeries, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleEpisode, Season: intp(1)}}}, "has no episode number"},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
+12
View File
@@ -159,6 +159,18 @@ func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Dow
return &d, nil return &d, nil
} }
// ExistsByInfohash сообщает, есть ли хоть одна загрузка (в любом состоянии)
// с данным infohash. Discovery усыновляет раздачу только если её ещё не
// видели — так готовые задачи не переобрабатываются на каждом тике.
func (s *Store) ExistsByInfohash(ctx context.Context, infohash string) (bool, error) {
var n int
if err := s.DB.GetContext(ctx, &n,
`SELECT COUNT(1) FROM download WHERE infohash = ?`, infohash); err != nil {
return false, fmt.Errorf("exists by infohash: %w", err)
}
return n > 0, nil
}
// SetDownloadState переводит загрузку в новое состояние. Ключ // SetDownloadState переводит загрузку в новое состояние. Ключ
// идемпотентности пересчитывается из текущего infohash: для терминального // идемпотентности пересчитывается из текущего infohash: для терминального
// состояния снимается (NULL), иначе равен infohash — так partial unique // состояния снимается (NULL), иначе равен infohash — так partial unique
+89
View File
@@ -228,3 +228,92 @@ func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) erro
} }
return nil return nil
} }
// --- Кандидаты базы метаданных (metadata_candidate) ---
// MetadataCandidate — строка таблицы metadata_candidate. provider/provider_id
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
// см. recognize), а не обязательно нативный id провайдера поиска.
type MetadataCandidate struct {
ID int64 `db:"id"`
RecognitionID int64 `db:"recognition_id"`
Provider string `db:"provider"`
ProviderID string `db:"provider_id"`
Title sql.NullString `db:"title"`
Year sql.NullInt64 `db:"year"`
Chosen bool `db:"chosen"`
CreatedAt string `db:"created_at"`
}
// CreateCandidates вставляет кандидатов распознавания одной транзакцией.
func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate) error {
if len(cands) == 0 {
return nil
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
const q = `
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year)
VALUES (?, ?, ?, ?, ?)`
for _, c := range cands {
if _, err := tx.ExecContext(ctx, q,
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year); err != nil {
return fmt.Errorf("insert candidate: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit candidates: %w", err)
}
return nil
}
// ListCandidatesByRecognition возвращает кандидатов попытки распознавания.
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]MetadataCandidate, error) {
var out []MetadataCandidate
if err := s.DB.SelectContext(ctx, &out,
`SELECT * FROM metadata_candidate WHERE recognition_id = ? ORDER BY id`, recognitionID); err != nil {
return nil, fmt.Errorf("list candidates: %w", err)
}
return out, nil
}
// GetCandidate возвращает кандидата по id либо (nil, nil).
func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) {
var c MetadataCandidate
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get candidate %d: %w", id, err)
}
return &c, nil
}
// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
// той же попытке распознавания.
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`UPDATE metadata_candidate SET chosen = 0 WHERE recognition_id = ?`, recognitionID); err != nil {
return fmt.Errorf("clear chosen: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE metadata_candidate SET chosen = 1 WHERE id = ? AND recognition_id = ?`,
candidateID, recognitionID); err != nil {
return fmt.Errorf("set chosen: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit chosen: %w", err)
}
return nil
}
+85
View File
@@ -159,6 +159,91 @@ func TestFileLinks_BatchLifecycle(t *testing.T) {
} }
} }
func TestCandidates_Lifecycle(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
dl := seedDownload(t, st)
recID, err := st.CreateRecognition(ctx, &Recognition{DownloadID: dl}, nil)
if err != nil {
t.Fatalf("create recognition: %v", err)
}
cands := []MetadataCandidate{
{RecognitionID: recID, Provider: "tvdb", ProviderID: "269613",
Title: NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true}},
{RecognitionID: recID, Provider: "tmdb", ProviderID: "60622",
Title: NullString("Fargo")},
}
if err := st.CreateCandidates(ctx, cands); err != nil {
t.Fatalf("create candidates: %v", err)
}
got, err := st.ListCandidatesByRecognition(ctx, recID)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(got) != 2 || got[0].Provider != "tvdb" || got[0].ProviderID != "269613" {
t.Fatalf("candidates = %+v", got)
}
chosenID := got[0].ID
if err := st.SetCandidateChosen(ctx, recID, chosenID); err != nil {
t.Fatalf("set chosen: %v", err)
}
got, _ = st.ListCandidatesByRecognition(ctx, recID)
for _, c := range got {
want := c.ID == chosenID
if c.Chosen != want {
t.Errorf("candidate %d chosen = %v, want %v", c.ID, c.Chosen, want)
}
}
// GetCandidate + переотметка.
single, err := st.GetCandidate(ctx, got[1].ID)
if err != nil || single == nil || single.Provider != "tmdb" {
t.Fatalf("get candidate = %+v, %v", single, err)
}
if err := st.SetCandidateChosen(ctx, recID, got[1].ID); err != nil {
t.Fatal(err)
}
got, _ = st.ListCandidatesByRecognition(ctx, recID)
if got[0].Chosen || !got[1].Chosen {
t.Errorf("re-choose failed: %+v", got)
}
}
func TestExistsByInfohash(t *testing.T) {
st := newTestStore(t)
ctx := context.Background()
const ih = "aabbccddeeff00112233445566778899aabbccdd"
exists, err := st.ExistsByInfohash(ctx, ih)
if err != nil || exists {
t.Fatalf("пусто: exists=%v err=%v", exists, err)
}
if _, err := st.CreateDownload(ctx, newDownloading(ih)); err != nil {
t.Fatal(err)
}
exists, err = st.ExistsByInfohash(ctx, ih)
if err != nil || !exists {
t.Fatalf("после вставки: exists=%v err=%v", exists, err)
}
// Терминальное состояние тоже считается «видели» (не реусыновляем).
id, _ := st.CreateDownload(ctx, newDownloading("ffffffffffffffffffffffffffffffffffffffff"))
_ = st.SetDownloadState(ctx, id, StateDone, "", "")
if ex, _ := st.ExistsByInfohash(ctx, "ffffffffffffffffffffffffffffffffffffffff"); !ex {
t.Error("done-задача должна считаться существующей")
}
}
func TestGetCandidate_None(t *testing.T) {
st := newTestStore(t)
c, err := st.GetCandidate(context.Background(), 999)
if err != nil || c != nil {
t.Errorf("want nil,nil; got %+v, %v", c, err)
}
}
func TestLatestBatchID_None(t *testing.T) { func TestLatestBatchID_None(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
dl := seedDownload(t, st) dl := seedDownload(t, st)
+300
View File
@@ -0,0 +1,300 @@
package tgbot
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// teleAPI — нужная боту часть клиента Telegram (его реализует
// *tgbotapi.BotAPI; в тестах подменяется фейком).
type teleAPI interface {
Send(c tgbotapi.Chattable) (tgbotapi.Message, error)
Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error)
GetUpdatesChan(config tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel
StopReceivingUpdates()
}
// Ingestor принимает загрузку (ingest.Service).
type Ingestor interface {
Ingest(ctx context.Context, req ingest.Request) (ingest.Result, error)
}
// Reviewer — операции ревью (worker.Worker).
type Reviewer interface {
ReviewData(ctx context.Context, id int64) (*worker.ReviewData, error)
Apply(ctx context.Context, id int64) error
Refine(ctx context.Context, id int64, hint string) error
SetType(ctx context.Context, id int64, mediaType string) error
Defer(ctx context.Context, id int64) error
Cancel(ctx context.Context, id int64) error
}
// Config — параметры бота.
type Config struct {
AllowedUserIDs []int64
WebBaseURL string // для deep-link «открыть в вебе» (опц.)
}
// Bot — Telegram-адаптер: приём загрузок и подтверждение раскладки.
type Bot struct {
api teleAPI
ingestor Ingestor
reviewer Reviewer
allowed map[int64]bool
webBase string
log *slog.Logger
mu sync.Mutex // защищает pending
pending map[int64]int64 // chatID → downloadID, ждущий подсказку
}
// New собирает бота поверх клиента Telegram.
func New(client teleAPI, ing Ingestor, rev Reviewer, cfg Config, log *slog.Logger) *Bot {
allowed := make(map[int64]bool, len(cfg.AllowedUserIDs))
for _, id := range cfg.AllowedUserIDs {
allowed[id] = true
}
return &Bot{
api: client,
ingestor: ing,
reviewer: rev,
allowed: allowed,
webBase: strings.TrimRight(cfg.WebBaseURL, "/"),
log: log,
pending: map[int64]int64{},
}
}
const pollTimeout = 30 // секунд long-poll
// Run крутит цикл обновлений до отмены ctx.
func (b *Bot) Run(ctx context.Context) {
b.log.Info("telegram bot started", "allowed_users", len(b.allowed))
cfg := tgbotapi.NewUpdate(0)
cfg.Timeout = pollTimeout
cfg.AllowedUpdates = []string{"message", "callback_query"}
updates := b.api.GetUpdatesChan(cfg)
defer b.api.StopReceivingUpdates()
for {
select {
case <-ctx.Done():
b.log.Info("telegram bot stopped")
return
case u, ok := <-updates:
if !ok {
return
}
b.handleUpdate(ctx, u)
}
}
}
func (b *Bot) handleUpdate(ctx context.Context, u tgbotapi.Update) {
switch {
case u.Message != nil:
b.handleMessage(ctx, u.Message)
case u.CallbackQuery != nil:
b.handleCallback(ctx, u.CallbackQuery)
}
}
// --- Входящие сообщения ---
func (b *Bot) handleMessage(ctx context.Context, m *tgbotapi.Message) {
if m.From == nil || m.Chat == nil {
return
}
if !b.allowed[m.From.ID] {
b.log.Warn("telegram: denied user", "user_id", m.From.ID, "username", m.From.UserName)
b.send(m.Chat.ID, "Доступ запрещён.", nil)
return
}
text := strings.TrimSpace(m.Text)
// Ждём подсказку для перераспознавания?
if id, ok := b.takePending(m.Chat.ID); ok && !strings.Contains(text, "magnet:") {
if err := b.reviewer.Refine(ctx, id, text); err != nil {
b.send(m.Chat.ID, "Не удалось: "+err.Error(), nil)
return
}
b.send(m.Chat.ID, "Подсказка принята, перераспознаю #"+strconv.FormatInt(id, 10)+"…", nil)
return
}
if text == "/start" || text == "/help" {
b.send(m.Chat.ID, helpText, nil)
return
}
source, context, ok := ParseMessage(text)
if !ok {
b.send(m.Chat.ID, "Не вижу magnet-ссылки. Перешлите сообщение торрент-бота или пришлите magnet.", nil)
return
}
res, err := b.ingestor.Ingest(ctx, ingest.Request{Source: source, Context: context})
if err != nil {
b.send(m.Chat.ID, "Ошибка приёма: "+err.Error(), nil)
return
}
msg := fmt.Sprintf("Принято #%d — %s.", res.DownloadID, res.State)
if res.Deduplicated {
msg = fmt.Sprintf("Уже в работе #%d — %s.", res.DownloadID, res.State)
}
b.send(m.Chat.ID, msg+"\nПозову, когда нужно подтверждение.", nil)
}
const helpText = `jellybit-бот: пришлите magnet-ссылку или перешлите сообщение торрент-бота поставлю на закачку.
Когда раздача скачается и потребуется подтверждение раскладки, позову кнопками.`
// --- Колбэки (кнопки) ---
func (b *Bot) handleCallback(ctx context.Context, cq *tgbotapi.CallbackQuery) {
if cq.From == nil || cq.Message == nil || cq.Message.Chat == nil {
return
}
if !b.allowed[cq.From.ID] {
b.answer(cq.ID, "Доступ запрещён")
return
}
action, id, val := parseCallback(cq.Data)
if id == 0 {
b.answer(cq.ID, "")
return
}
chatID := cq.Message.Chat.ID
msgID := cq.Message.MessageID
var note string
var err error
switch action {
case "apply":
err = b.reviewer.Apply(ctx, id)
note = "Применяю…"
case "defer":
err = b.reviewer.Defer(ctx, id)
note = "Отложено"
case "reject":
err = b.reviewer.Cancel(ctx, id)
note = "Отклонено"
case "type":
err = b.reviewer.SetType(ctx, id, val)
note = "Меняю тип…"
case "refine":
b.setPending(chatID, id)
b.answer(cq.ID, "Жду подсказку")
b.send(chatID, "Ответьте сообщением с подсказкой для #"+strconv.FormatInt(id, 10)+".", nil)
return
default:
b.answer(cq.ID, "")
return
}
if err != nil {
b.answer(cq.ID, "Ошибка")
b.send(chatID, "Не удалось: "+err.Error(), nil)
return
}
b.answer(cq.ID, note)
b.refreshCard(ctx, chatID, msgID, id)
}
// refreshCard перечитывает задачу и обновляет карточку на месте.
func (b *Bot) refreshCard(ctx context.Context, chatID int64, msgID int, id int64) {
rd, err := b.reviewer.ReviewData(ctx, id)
if err != nil {
b.log.Warn("telegram: refresh card failed", "download_id", id, "err", err)
return
}
text, kb := b.renderCard(rd)
var edit tgbotapi.EditMessageTextConfig
if kb != nil {
edit = tgbotapi.NewEditMessageTextAndMarkup(chatID, msgID, text, *kb)
} else {
edit = tgbotapi.NewEditMessageText(chatID, msgID, text)
}
if _, err := b.api.Send(edit); err != nil {
b.log.Warn("telegram: edit card failed", "download_id", id, "err", err)
}
}
// --- Notifier (worker.Notifier) ---
// Notify шлёт карточку подтверждения/готовности всем доверенным пользователям.
func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyEvent) {
rd, err := b.reviewer.ReviewData(ctx, downloadID)
if err != nil {
b.log.Warn("telegram: notify review data", "download_id", downloadID, "err", err)
return
}
var text string
var kb *tgbotapi.InlineKeyboardMarkup
switch event {
case worker.EventDone:
text = b.renderDone(rd)
default:
text, kb = b.renderCard(rd)
}
for chatID := range b.allowed {
b.send(chatID, text, kb)
}
}
// --- Отправка/хелперы ---
func (b *Bot) send(chatID int64, text string, kb *tgbotapi.InlineKeyboardMarkup) {
msg := tgbotapi.NewMessage(chatID, text)
msg.DisableWebPagePreview = true
if kb != nil {
msg.ReplyMarkup = *kb
}
if _, err := b.api.Send(msg); err != nil {
b.log.Warn("telegram: send failed", "chat_id", chatID, "err", err)
}
}
func (b *Bot) answer(callbackID, text string) {
if _, err := b.api.Request(tgbotapi.NewCallback(callbackID, text)); err != nil {
b.log.Warn("telegram: answer callback failed", "err", err)
}
}
func (b *Bot) setPending(chatID, id int64) {
b.mu.Lock()
b.pending[chatID] = id
b.mu.Unlock()
}
func (b *Bot) takePending(chatID int64) (int64, bool) {
b.mu.Lock()
defer b.mu.Unlock()
id, ok := b.pending[chatID]
if ok {
delete(b.pending, chatID)
}
return id, ok
}
// parseCallback разбирает "action[:id[:value]]".
func parseCallback(data string) (action string, id int64, value string) {
parts := strings.Split(data, ":")
action = parts[0]
if len(parts) > 1 {
id, _ = strconv.ParseInt(parts[1], 10, 64)
}
if len(parts) > 2 {
value = parts[2]
}
return action, id, value
}
+264
View File
@@ -0,0 +1,264 @@
package tgbot
import (
"context"
"io"
"log/slog"
"strings"
"testing"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"git.vakhrushev.me/av/jellybit/internal/ingest"
"git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// fakeAPI записывает исходящие Chattable; обновления не нужны (хендлеры зовём
// напрямую).
type fakeAPI struct {
sent []sentMsg
edits []sentMsg
answers []string
}
type sentMsg struct {
chatID int64
text string
hasKB bool
}
func (f *fakeAPI) Send(c tgbotapi.Chattable) (tgbotapi.Message, error) {
switch m := c.(type) {
case tgbotapi.MessageConfig:
f.sent = append(f.sent, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil})
case tgbotapi.EditMessageTextConfig:
f.edits = append(f.edits, sentMsg{m.ChatID, m.Text, m.ReplyMarkup != nil})
}
return tgbotapi.Message{MessageID: 1}, nil
}
func (f *fakeAPI) Request(c tgbotapi.Chattable) (*tgbotapi.APIResponse, error) {
if cb, ok := c.(tgbotapi.CallbackConfig); ok {
f.answers = append(f.answers, cb.Text)
}
return &tgbotapi.APIResponse{Ok: true}, nil
}
func (f *fakeAPI) GetUpdatesChan(tgbotapi.UpdateConfig) tgbotapi.UpdatesChannel { return nil }
func (f *fakeAPI) StopReceivingUpdates() {}
type fakeIngestor struct {
lastReq ingest.Request
res ingest.Result
}
func (f *fakeIngestor) Ingest(_ context.Context, req ingest.Request) (ingest.Result, error) {
f.lastReq = req
return f.res, nil
}
type fakeReviewer struct {
data *worker.ReviewData
applied []int64
refined map[int64]string
typed map[int64]string
deferred []int64
canceled []int64
}
func (f *fakeReviewer) ReviewData(context.Context, int64) (*worker.ReviewData, error) {
return f.data, nil
}
func (f *fakeReviewer) Apply(_ context.Context, id int64) error {
f.applied = append(f.applied, id)
return nil
}
func (f *fakeReviewer) Refine(_ context.Context, id int64, hint string) error {
if f.refined == nil {
f.refined = map[int64]string{}
}
f.refined[id] = hint
return nil
}
func (f *fakeReviewer) SetType(_ context.Context, id int64, t string) error {
if f.typed == nil {
f.typed = map[int64]string{}
}
f.typed[id] = t
return nil
}
func (f *fakeReviewer) Defer(_ context.Context, id int64) error {
f.deferred = append(f.deferred, id)
return nil
}
func (f *fakeReviewer) Cancel(_ context.Context, id int64) error {
f.canceled = append(f.canceled, id)
return nil
}
func reviewData(state store.State) *worker.ReviewData {
s, e := 2, 1
return &worker.ReviewData{
Download: store.Download{ID: 5, State: state, Context: "Фарго, второй сезон", SourceRef: "magnet:?x"},
Recognition: &store.Recognition{
Provider: store.NullString("tvdb"), ProviderID: store.NullString("269613"),
Reasons: `["неполный пак"]`,
},
Plan: recognize.Plan{
Type: recognize.MediaSeries, Title: "Фарго", Year: 2015,
Files: []recognize.PlanFile{{Src: "e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}},
},
Preview: []layout.Link{
{Src: "e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
},
}
}
func newTestBot(t *testing.T, allowed []int64) (*Bot, *fakeAPI, *fakeIngestor, *fakeReviewer) {
t.Helper()
api := &fakeAPI{}
ing := &fakeIngestor{res: ingest.Result{DownloadID: 5, State: store.StateDownloading}}
rev := &fakeReviewer{data: reviewData(store.StateReview)}
b := New(api, ing, rev, Config{AllowedUserIDs: allowed, WebBaseURL: "http://host:8080"},
slog.New(slog.NewTextHandler(io.Discard, nil)))
return b, api, ing, rev
}
func msgFrom(userID int64, text string) *tgbotapi.Message {
return &tgbotapi.Message{
MessageID: 1, From: &tgbotapi.User{ID: userID}, Chat: &tgbotapi.Chat{ID: userID}, Text: text,
}
}
func TestBot_IngestFromMagnet(t *testing.T) {
b, api, ing, _ := newTestBot(t, []int64{7})
b.handleMessage(context.Background(), msgFrom(7, "крутой сериал\nmagnet:?xt=urn:btih:ABC"))
if !strings.HasPrefix(ing.lastReq.Source, "magnet:?xt=urn:btih:ABC") {
t.Errorf("source = %q", ing.lastReq.Source)
}
if ing.lastReq.Context != "крутой сериал" {
t.Errorf("context = %q", ing.lastReq.Context)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Принято #5") {
t.Errorf("sent = %+v", api.sent)
}
}
func TestBot_DeniesUnknownUser(t *testing.T) {
b, api, ing, _ := newTestBot(t, []int64{7})
b.handleMessage(context.Background(), msgFrom(999, "magnet:?xt=urn:btih:ABC"))
if len(ing.lastReq.Source) != 0 {
t.Error("ingest не должен вызываться для чужого пользователя")
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Доступ запрещён") {
t.Errorf("sent = %+v", api.sent)
}
}
func TestBot_NoMagnet(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7})
b.handleMessage(context.Background(), msgFrom(7, "привет"))
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Не вижу magnet") {
t.Errorf("sent = %+v", api.sent)
}
}
func TestBot_RefineViaReply(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
// Кнопка «Уточнить» поставила ожидание подсказки для чата 7.
b.setPending(7, 5)
b.handleMessage(context.Background(), msgFrom(7, "это второй сезон"))
if rev.refined[5] != "это второй сезон" {
t.Errorf("refine = %v", rev.refined)
}
}
func cbFrom(userID int64, data string) *tgbotapi.CallbackQuery {
return &tgbotapi.CallbackQuery{
ID: "cb", From: &tgbotapi.User{ID: userID}, Data: data,
Message: &tgbotapi.Message{MessageID: 99, Chat: &tgbotapi.Chat{ID: userID}},
}
}
func TestBot_CallbackApply(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "apply:5"))
if len(rev.applied) != 1 || rev.applied[0] != 5 {
t.Errorf("applied = %v", rev.applied)
}
if len(api.answers) != 1 {
t.Errorf("answers = %v", api.answers)
}
if len(api.edits) != 1 { // карточка обновлена на месте
t.Errorf("edits = %v", api.edits)
}
}
func TestBot_CallbackType(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "type:5:movie"))
if rev.typed[5] != "movie" {
t.Errorf("typed = %v", rev.typed)
}
}
func TestBot_CallbackRefineSetsPending(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(7, "refine:5"))
if id, ok := b.takePending(7); !ok || id != 5 {
t.Errorf("pending = %d,%v", id, ok)
}
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "подсказкой") {
t.Errorf("sent = %+v", api.sent)
}
}
func TestBot_CallbackDeniesUnknown(t *testing.T) {
b, _, _, rev := newTestBot(t, []int64{7})
b.handleCallback(context.Background(), cbFrom(999, "apply:5"))
if len(rev.applied) != 0 {
t.Error("чужой колбэк не должен исполняться")
}
}
func TestBot_NotifyReview(t *testing.T) {
b, api, _, _ := newTestBot(t, []int64{7, 8})
b.Notify(context.Background(), 5, worker.EventReview)
if len(api.sent) != 2 { // обоим доверенным
t.Fatalf("sent to %d chats, want 2", len(api.sent))
}
if !strings.Contains(api.sent[0].text, "Нужно подтверждение #5") {
t.Errorf("card text = %q", api.sent[0].text)
}
if !api.sent[0].hasKB {
t.Error("карточка ревью без клавиатуры")
}
}
func TestBot_NotifyDone(t *testing.T) {
b, api, _, rev := newTestBot(t, []int64{7})
rev.data = reviewData(store.StateDone)
b.Notify(context.Background(), 5, worker.EventDone)
if len(api.sent) != 1 || !strings.Contains(api.sent[0].text, "Готово") {
t.Errorf("sent = %+v", api.sent)
}
}
func TestParseCallback(t *testing.T) {
a, id, v := parseCallback("type:5:series")
if a != "type" || id != 5 || v != "series" {
t.Errorf("got %q %d %q", a, id, v)
}
a, id, v = parseCallback("apply:9")
if a != "apply" || id != 9 || v != "" {
t.Errorf("got %q %d %q", a, id, v)
}
}
-4
View File
@@ -1,4 +0,0 @@
// Package tgbot — Telegram-адаптер, парсер сообщений бота и исходящие пинги.
//
// Заглушка: реализация в фазе Ф5 (см. docs/specs/review-ux.md).
package tgbot
+73
View File
@@ -0,0 +1,73 @@
// Package tgbot — Telegram-адаптер: приём magnet/пересланных сообщений
// торрент-бота, подтверждение раскладки кнопками и исходящие пинги.
package tgbot
import (
"regexp"
"strings"
)
var (
magnetRe = regexp.MustCompile(`magnet:\?[^\s]+`)
// parenURL — markdown-хвост " (https://…)" в строках сообщения бота.
parenURL = regexp.MustCompile(`\s*\(https?://[^)]+\)`)
)
// noisePrefixes — начала строк UI торрент-бота, которые в контекст не несём.
var noisePrefixes = []string{
"Открыть magnet", "или получить .torrent", "Оценить", "Следить",
"В закладки", "Добавить в закладки", "cправка", "справка",
"[список файлов]", "[список файлов]", "⚡", "сохранённая копия",
}
// ParseMessage извлекает из текста magnet-ссылку и человекочитаемый контекст
// (заголовок релиза без ссылок, команд и UI-мусора бота). ok=false, если
// magnet не найден. Текст может быть как «сырым» magnet, так и пересланным
// сообщением торрент-бота (формат — см. tmp/examples.md).
func ParseMessage(text string) (source, context string, ok bool) {
m := magnetRe.FindString(text)
if m == "" {
return "", "", false
}
return m, cleanContext(text, m), true
}
// cleanContext оставляет содержательные строки (заголовок, метаданные),
// выкидывая ссылки, команды (/...) и UI-строки бота.
func cleanContext(text, magnet string) string {
var keep []string
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.Contains(line, magnet) {
continue
}
if isNoise(line) {
continue
}
line = strings.TrimSpace(parenURL.ReplaceAllString(line, ""))
if line != "" {
keep = append(keep, line)
}
}
return strings.Join(keep, "\n")
}
func isNoise(line string) bool {
if strings.HasPrefix(line, "/") {
return true
}
// Строки-ссылки и строки рейтинга/команд бота (содержат /g_ /r_ /us_ …).
if strings.Contains(line, "hashurl.ru") || strings.Contains(line, "exfreedomist.com") {
return true
}
for _, p := range noisePrefixes {
if strings.HasPrefix(line, p) {
return true
}
}
// Строки вида "👍: /g_ad035f или 👎🏿: /r_ad035f".
if strings.Contains(line, ": /") {
return true
}
return false
}
+77
View File
@@ -0,0 +1,77 @@
package tgbot
import (
"strings"
"testing"
)
// botMessage — реальный блок из tmp/examples.md (Аватар, Книга 2).
const botMessage = `[3] #3117703 [rutracker], 2020-01-30 (https://hashurl.ru/eyJhbGc.abc):
Аватар: Легенда об Аанге / Avatar: The Last Airbender / Книга 2: Земля / Серии: 1-20 из 20 (Майкл Данте ДиМартино / Michael Dante DiMartino, Брайан Кониецко / Bryan Konietzko) [2006, США, приключения, фэнтези, DVDRip-AVC] Dub + Original
(проверено) | 4 GB
сохранённая копия описания раздачи (https://hashurl.ru/eyJ.def)
magnet:?xt=urn:btih:7931AA3ED6666746012F5739D099B5BC64D72A16&tr=http%3A%2F%2Fbt2.t-ru.org%2Fann%3Fmagnet&dn=rutracker-topic-3117703
Открыть magnet в вашем клиенте (https://hashurl.ru/eyJ.ghi)
или получить .torrent: /tr_bc8ea
Оценить:
👍: /g_ad035f или 👎🏿: /r_ad035f
[список файлов] (https://download.exfreedomist.com/files/7931AA3E)
Следить: /us_bc8ea
В закладки: /mka_46d1e
cправка: /help, настройки: /settings`
func TestParseMessage_BotForward(t *testing.T) {
src, ctx, ok := ParseMessage(botMessage)
if !ok {
t.Fatal("magnet не найден")
}
if !strings.HasPrefix(src, "magnet:?xt=urn:btih:7931AA3E") {
t.Errorf("magnet = %q", src)
}
// Контекст несёт заголовок релиза и метаданные.
for _, want := range []string{"Avatar: The Last Airbender", "Книга 2", "2006", "DVDRip-AVC", "✅ (проверено) | 4 GB"} {
if !strings.Contains(ctx, want) {
t.Errorf("контекст без %q:\n%s", want, ctx)
}
}
// Мусор и ссылки вычищены.
for _, bad := range []string{"magnet:", "hashurl.ru", "/tr_", "/g_ad035f", "Следить", "cправка", "список файлов"} {
if strings.Contains(ctx, bad) {
t.Errorf("контекст содержит мусор %q:\n%s", bad, ctx)
}
}
}
func TestParseMessage_PlainMagnet(t *testing.T) {
src, ctx, ok := ParseMessage("magnet:?xt=urn:btih:ABC123&dn=x")
if !ok || src != "magnet:?xt=urn:btih:ABC123&dn=x" {
t.Errorf("src = %q, ok = %v", src, ok)
}
if ctx != "" {
t.Errorf("ожидался пустой контекст, got %q", ctx)
}
}
func TestParseMessage_MagnetWithUserText(t *testing.T) {
text := "вот сериал, второй сезон\nmagnet:?xt=urn:btih:DEF456"
src, ctx, ok := ParseMessage(text)
if !ok || !strings.HasPrefix(src, "magnet:?xt=urn:btih:DEF456") {
t.Errorf("src = %q", src)
}
if ctx != "вот сериал, второй сезон" {
t.Errorf("context = %q", ctx)
}
}
func TestParseMessage_NoMagnet(t *testing.T) {
if _, _, ok := ParseMessage("просто текст без ссылки"); ok {
t.Error("ожидалось ok=false без magnet")
}
}
+182
View File
@@ -0,0 +1,182 @@
package tgbot
import (
"fmt"
"path/filepath"
"strconv"
"strings"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"git.vakhrushev.me/av/jellybit/internal/store"
"git.vakhrushev.me/av/jellybit/internal/worker"
)
// renderCard строит текст и клавиатуру карточки по состоянию задачи.
func (b *Bot) renderCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
id := rd.Download.ID
state := rd.Download.State
switch state {
case store.StateReview, store.StateDeferred:
return b.reviewCard(rd)
case store.StateRecognizing:
return "⏳ Распознаю #" + itoa(id) + "…", b.webOnly(id)
case store.StateLinking:
return "⏳ Раскладываю #" + itoa(id) + "…", nil
case store.StateDone:
return b.renderDone(rd), b.webOnly(id)
default:
text := fmt.Sprintf("Задача #%d — %s.", id, state)
if msg := rd.Download.ErrorMsg.String; msg != "" {
text += "\n" + msg
}
return text, b.webOnly(id)
}
}
func (b *Bot) reviewCard(rd *worker.ReviewData) (string, *tgbotapi.InlineKeyboardMarkup) {
id := rd.Download.ID
var sb strings.Builder
fmt.Fprintf(&sb, "🟡 Нужно подтверждение #%d\n", id)
if src := contextOrSource(rd); src != "" {
fmt.Fprintf(&sb, "Источник: %s\n", shorten(src, 80))
}
fmt.Fprintf(&sb, "Похоже на: %s\n", guessLine(rd))
if base := baseLine(rd.Recognition); base != "" {
fmt.Fprintf(&sb, "База: %s\n", base)
}
if reasons := rd.Recognition.ReasonList(); len(reasons) > 0 {
fmt.Fprintf(&sb, "Причины: %s\n", strings.Join(reasons, " · "))
}
if n := len(rd.Preview); n > 0 {
fmt.Fprintf(&sb, "План: %d файлов → %s", n, tailPath(rd.Preview[0].Dst))
}
return strings.TrimRight(sb.String(), "\n"), b.reviewKeyboard(rd)
}
func (b *Bot) reviewKeyboard(rd *worker.ReviewData) *tgbotapi.InlineKeyboardMarkup {
id := rd.Download.ID
sid := itoa(id)
var row1 []tgbotapi.InlineKeyboardButton
if len(rd.Preview) > 0 {
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("✅ Применить", "apply:"+sid))
}
row1 = append(row1, tgbotapi.NewInlineKeyboardButtonData("📺↔🎬 Тип", "type:"+sid+":"+oppositeType(string(rd.Plan.Type))))
row2 := tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonData("🔁 Уточнить", "refine:"+sid),
tgbotapi.NewInlineKeyboardButtonData("🕗 Позже", "defer:"+sid),
)
var row3 []tgbotapi.InlineKeyboardButton
if url := b.reviewURL(id); url != "" {
row3 = append(row3, tgbotapi.NewInlineKeyboardButtonURL("🌐 В вебе", url))
}
row3 = append(row3, tgbotapi.NewInlineKeyboardButtonData("❌ Отклонить", "reject:"+sid))
kb := tgbotapi.NewInlineKeyboardMarkup(row1, row2, row3)
return &kb
}
// renderDone — короткое сообщение о готовности.
func (b *Bot) renderDone(rd *worker.ReviewData) string {
title := rd.Plan.Title
if title == "" {
title = "#" + itoa(rd.Download.ID)
}
n := len(rd.Preview)
if n == 0 {
return fmt.Sprintf("✅ Готово: «%s» разложен.", title)
}
return fmt.Sprintf("✅ Готово: «%s» — разложено файлов: %d.", title, n)
}
func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup {
url := b.reviewURL(id)
if url == "" {
return nil
}
kb := tgbotapi.NewInlineKeyboardMarkup(tgbotapi.NewInlineKeyboardRow(
tgbotapi.NewInlineKeyboardButtonURL("🌐 Открыть в вебе", url),
))
return &kb
}
func (b *Bot) reviewURL(id int64) string {
if b.webBase == "" {
return ""
}
return b.webBase + "/review/" + itoa(id)
}
// --- мелкие хелперы ---
func guessLine(rd *worker.ReviewData) string {
emoji, kind := "🎬", "фильм"
if rd.Plan.Type == "series" {
emoji, kind = "📺", "сериал"
}
title := rd.Plan.Title
if title == "" {
title = "не распознано"
}
s := fmt.Sprintf("%s %s «%s»", emoji, kind, title)
if rd.Plan.Year != 0 {
s += fmt.Sprintf(" (%d)", rd.Plan.Year)
}
return s
}
func baseLine(rec *store.Recognition) string {
if rec == nil || !rec.Provider.Valid || rec.Provider.String == "" || rec.Provider.String == "none" {
return "нет матча"
}
if rec.ProviderID.Valid && rec.ProviderID.String != "" {
return rec.Provider.String + " " + rec.ProviderID.String
}
return rec.Provider.String
}
func contextOrSource(rd *worker.ReviewData) string {
if c := strings.TrimSpace(rd.Download.Context); c != "" {
return firstLine(c)
}
return rd.Download.SourceRef
}
func oppositeType(t string) string {
if t == "series" {
return "movie"
}
return "series"
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func tailPath(p string) string {
dir, file := filepath.Split(p)
parent := filepath.Base(strings.TrimRight(dir, "/"))
if parent == "." || parent == "/" || parent == "" {
return file
}
return parent + "/" + file
}
func shorten(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
+93
View File
@@ -0,0 +1,93 @@
package worker
import (
"context"
"strings"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
// discover усыновляет новые раздачи: для каждого торрента с нашей категорией
// ИЛИ тегом, чьего infohash ещё нет в БД, заводит задачу downloading. Дальше
// её ведёт обычный reconcile. Вызывается под w.mu.
//
// Корректность при гонке с Ingest (другая горутина): Ingest пишет строку в
// БД до добавления в qBit и ставит idempotency_key=infohash, на который есть
// UNIQUE-индекс. Поэтому даже если тик и Ingest столкнутся в окне «проверил →
// вставляю», второй INSERT упадёт на индексе, и adopt просто пропустит.
func (w *Worker) discover(ctx context.Context, torrents []qbt.Torrent) {
for _, t := range torrents {
if w.tracked(t) {
w.adopt(ctx, t)
}
}
}
// tracked — относится ли торрент к jellybit (категория или тег из конфига).
func (w *Worker) tracked(t qbt.Torrent) bool {
if w.cfg.Category != "" && t.Category == w.cfg.Category {
return true
}
return hasTag(t.Tags, w.cfg.Tag)
}
// adopt заводит задачу под торрент, если его ещё не видели.
func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
infohash := firstInfohash(t)
if infohash == "" {
return // нечем идентифицировать (напр. ещё metaDL без хэша)
}
exists, err := w.store.ExistsByInfohash(ctx, infohash)
if err != nil {
w.log.Warn("discover: exists check failed", "infohash", infohash, "err", err)
return
}
if exists {
return // уже усыновлён ранее (или обработан) — не трогаем
}
d := &store.Download{
SourceType: store.SourceMagnet,
SourceRef: "magnet:?xt=urn:btih:" + infohash,
Infohash: store.NullString(infohash),
IdempotencyKey: store.NullString(infohash),
State: store.StateDownloading,
}
id, err := w.store.CreateDownload(ctx, d)
if err != nil {
// Гонка: Ingest/другой тик мог вставить запись между проверкой и
// вставкой — UNIQUE-индекс это отсёк. Если запись появилась, всё ок.
if ex, _ := w.store.ExistsByInfohash(ctx, infohash); ex {
return
}
w.log.Error("discover: adopt failed", "infohash", infohash, "err", err)
return
}
w.log.Info("discover: adopted torrent",
"download_id", id, "infohash", infohash, "name", t.Name,
"category", t.Category, "tags", t.Tags)
}
// hasTag сообщает, есть ли tag среди списка тегов qBit (через запятую).
func hasTag(tags, tag string) bool {
if tag == "" {
return false
}
for _, x := range strings.Split(tags, ",") {
if strings.TrimSpace(x) == tag {
return true
}
}
return false
}
// firstInfohash возвращает первый непустой infohash торрента (нижний регистр).
func firstInfohash(t qbt.Torrent) string {
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
if h != "" {
return strings.ToLower(h)
}
}
return ""
}
+146
View File
@@ -0,0 +1,146 @@
package worker
import (
"context"
"testing"
"git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/store"
)
const ihDisc = "7931aa3ed6666746012f5739d099b5bc64d72a16"
func emptyStore() *fakeStore {
return &fakeStore{downloads: map[int64]*store.Download{}}
}
// findByInfohash возвращает усыновлённую задачу по infohash.
func findByInfohash(st *fakeStore, infohash string) *store.Download {
for _, d := range st.downloads {
if d.Infohash.String == infohash {
return d
}
}
return nil
}
func TestDiscover_AdoptsByCategory(t *testing.T) {
st := emptyStore()
w := newTestWorker(st, &fakeQbt{})
w.discover(context.Background(), []qbt.Torrent{
{Hash: ihDisc, Name: "Avatar", Category: "jellybit", State: "stalledUP"},
})
d := findByInfohash(st, ihDisc)
if d == nil {
t.Fatal("раздача с категорией jellybit не усыновлена")
}
if d.State != store.StateDownloading || d.SourceType != store.SourceMagnet {
t.Errorf("adopted = %+v", d)
}
if d.IdempotencyKey.String != ihDisc {
t.Errorf("idempotency_key = %q", d.IdempotencyKey.String)
}
}
func TestDiscover_AdoptsByTag(t *testing.T) {
st := emptyStore()
w := newTestWorker(st, &fakeQbt{})
w.cfg.Tag = "jellybit"
// Категория чужая, но тег наш — усыновляем (не трогая категорию).
w.discover(context.Background(), []qbt.Torrent{
{Hash: ihDisc, Name: "Fargo", Category: "movies", Tags: "hd, jellybit, rus", State: "uploading"},
})
if findByInfohash(st, ihDisc) == nil {
t.Fatal("раздача с тегом jellybit не усыновлена")
}
}
func TestDiscover_SkipsUntracked(t *testing.T) {
st := emptyStore()
w := newTestWorker(st, &fakeQbt{})
w.cfg.Tag = "jellybit"
w.discover(context.Background(), []qbt.Torrent{
{Hash: ihDisc, Category: "movies", Tags: "hd, rus"},
})
if len(st.downloads) != 0 {
t.Errorf("чужая раздача не должна усыновляться: %+v", st.downloads)
}
}
func TestDiscover_SkipsExisting(t *testing.T) {
st := emptyStore()
// Уже есть задача (напр. терминальная done) — не переусыновляем.
st.downloads[1] = &store.Download{
ID: 1, State: store.StateDone, Infohash: store.NullString(ihDisc),
}
w := newTestWorker(st, &fakeQbt{})
w.discover(context.Background(), []qbt.Torrent{
{Hash: ihDisc, Category: "jellybit"},
})
if len(st.downloads) != 1 {
t.Errorf("существующий infohash не должен порождать новую задачу: %d", len(st.downloads))
}
}
func TestDiscover_SkipsNoInfohash(t *testing.T) {
st := emptyStore()
w := newTestWorker(st, &fakeQbt{})
w.discover(context.Background(), []qbt.Torrent{{Category: "jellybit"}})
if len(st.downloads) != 0 {
t.Error("без infohash усыновлять нечего")
}
}
// TestPoll_AdoptsAndCompletes — сценарий пользователя целиком: помеченная и
// уже скачанная раздача за один тик усыновляется и доходит до completed.
func TestPoll_AdoptsAndCompletes(t *testing.T) {
st := emptyStore()
qb := &fakeQbt{torrents: []qbt.Torrent{
{Hash: ihDisc, Name: "Avatar", Category: "other", Tags: "jellybit", State: "stalledUP"},
}}
w := newTestWorker(st, qb)
w.cfg.Tag = "jellybit"
if err := w.Poll(context.Background()); err != nil {
t.Fatalf("Poll: %v", err)
}
d := findByInfohash(st, ihDisc)
if d == nil {
t.Fatal("не усыновлено")
}
if d.State != store.StateCompleted {
t.Errorf("state = %q, want completed (готовая раздача)", d.State)
}
}
func TestHasTag(t *testing.T) {
cases := []struct {
tags, tag string
want bool
}{
{"jellybit", "jellybit", true},
{"hd, jellybit, rus", "jellybit", true},
{"hd,rus", "jellybit", false},
{"jellybit-extra", "jellybit", false},
{"", "jellybit", false},
{"jellybit", "", false},
}
for _, c := range cases {
if got := hasTag(c.tags, c.tag); got != c.want {
t.Errorf("hasTag(%q,%q) = %v, want %v", c.tags, c.tag, got, c.want)
}
}
}
func TestFirstInfohash(t *testing.T) {
if got := firstInfohash(qbt.Torrent{Hash: "ABC"}); got != "abc" {
t.Errorf("got %q", got)
}
if got := firstInfohash(qbt.Torrent{InfohashV2: "DEF"}); got != "def" {
t.Errorf("got %q", got)
}
if got := firstInfohash(qbt.Torrent{}); got != "" {
t.Errorf("got %q, want empty", got)
}
}
+38
View File
@@ -0,0 +1,38 @@
package worker
import (
"path/filepath"
"strings"
)
// translatePath переводит путь, как его сообщает qBittorrent API (save_path),
// в путь, видимый jellybit на хосте. Применяет самое длинное совпадение
// префикса из path_map (ключ — префикс в адресации qBit, значение — префикс на
// хосте). Совпадение — только по границам сегментов: ключ `/data` подходит для
// `/data` и `/data/x`, но не для `/data2`.
//
// Обычно path_map пуст — все медиа-приложения монтируют /srv/media:/srv/media
// идентично, поэтому путь из API уже равен хост-пути и возвращается как есть.
// Фолбэк нужен, если адресация qBit и jellybit разойдётся (см.
// docs/specs/architecture.md).
func translatePath(p string, pathMap map[string]string) string {
if len(pathMap) == 0 || p == "" {
return p
}
clean := filepath.Clean(p)
bestKey, bestVal := "", ""
for key, val := range pathMap {
k := filepath.Clean(key)
if clean != k && !strings.HasPrefix(clean, k+string(filepath.Separator)) {
continue
}
if len(k) > len(bestKey) {
bestKey, bestVal = k, val
}
}
if bestKey == "" {
return p
}
return filepath.Join(filepath.Clean(bestVal), strings.TrimPrefix(clean, bestKey))
}
+71
View File
@@ -0,0 +1,71 @@
package worker
import "testing"
func TestTranslatePath(t *testing.T) {
cases := []struct {
name string
path string
m map[string]string
want string
}{
{
name: "empty map returns path as is",
path: "/srv/media/downloads",
m: nil,
want: "/srv/media/downloads",
},
{
name: "no matching prefix returns path as is",
path: "/other/downloads",
m: map[string]string{"/data": "/srv/media"},
want: "/other/downloads",
},
{
name: "prefix translated on segment boundary",
path: "/data/downloads/Show/e1.mkv",
m: map[string]string{"/data": "/srv/media"},
want: "/srv/media/downloads/Show/e1.mkv",
},
{
name: "exact match of key",
path: "/data",
m: map[string]string{"/data": "/srv/media"},
want: "/srv/media",
},
{
name: "does not match partial segment",
path: "/data2/downloads",
m: map[string]string{"/data": "/srv/media"},
want: "/data2/downloads",
},
{
name: "longest matching prefix wins",
path: "/data/dl/x.mkv",
m: map[string]string{
"/data": "/srv/A",
"/data/dl": "/srv/B",
},
want: "/srv/B/x.mkv",
},
{
name: "trailing slashes in keys/values normalized",
path: "/data/downloads/x.mkv",
m: map[string]string{"/data/": "/srv/media/"},
want: "/srv/media/downloads/x.mkv",
},
{
name: "empty path returns empty",
path: "",
m: map[string]string{"/data": "/srv/media"},
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := translatePath(tc.path, tc.m); got != tc.want {
t.Errorf("translatePath(%q) = %q, want %q", tc.path, got, tc.want)
}
})
}
}
+281 -34
View File
@@ -7,9 +7,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/metadata"
"git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
@@ -19,6 +21,11 @@ import (
const ( const (
ovrMediaType = "media_type" ovrMediaType = "media_type"
ovrIgnoredFiles = "ignored_files" ovrIgnoredFiles = "ignored_files"
ovrProvider = "provider" // выбранная база ("none" = без базы)
ovrProviderID = "provider_id" // id в выбранной базе
ovrTitle = "title" // запиненное каноническое название
ovrYear = "year" // запиненный год
ovrForceReview = "force_review" // ручная перепривязка: не авто-раскладывать
) )
// recognizePending распознаёт завершённые загрузки и перезапускает те, что // recognizePending распознаёт завершённые загрузки и перезапускает те, что
@@ -75,14 +82,14 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
// относительных путей файлов в абсолютные при раскладке. // относительных путей файлов в абсолютные при раскладке.
func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) { func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) {
if !d.Infohash.Valid { if !d.Infohash.Valid {
return recognize.Result{}, "", fmt.Errorf("нет infohash") return recognize.Result{}, "", fmt.Errorf("no infohash")
} }
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String) t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
if err != nil { if err != nil {
return recognize.Result{}, "", err return recognize.Result{}, "", err
} }
if !ok { if !ok {
return recognize.Result{}, "", fmt.Errorf("торрент не найден в qBittorrent") return recognize.Result{}, "", fmt.Errorf("torrent not found in qBittorrent")
} }
files, err := w.qbt.Files(ctx, t.Hash) files, err := w.qbt.Files(ctx, t.Hash)
if err != nil { if err != nil {
@@ -103,11 +110,12 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
in.Files[i] = recognize.File{Path: f.Name, Size: f.Size} in.Files[i] = recognize.File{Path: f.Name, Size: f.Size}
} }
savePath := translatePath(t.SavePath, w.cfg.PathMap)
res, err := w.recognizer.Recognize(ctx, in) res, err := w.recognizer.Recognize(ctx, in)
if err != nil { if err != nil {
return recognize.Result{}, t.SavePath, err return recognize.Result{}, savePath, err
} }
return res, t.SavePath, nil return res, savePath, nil
} }
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3 // finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
@@ -158,15 +166,26 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
"download_id", id, "state", d.State) "download_id", id, "state", d.State)
return return
} }
if _, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons); err != nil { recID, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons)
if err != nil {
w.log.Error("recognize: persist", "download_id", id, "err", err) w.log.Error("recognize: persist", "download_id", id, "err", err)
return return
} }
// Кандидаты базы — для ручного выбора в review.
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
if err := w.store.CreateCandidates(ctx, cands); err != nil {
w.log.Warn("recognize: persist candidates", "download_id", id, "err", err)
}
}
// Авто-раскладка при подтверждённом матче и чистой валидации (Ф4); // Авто-раскладка при подтверждённом матче и чистой валидации (Ф4);
// иначе — review. Раскладчик может быть не сконфигурирован. // иначе — review. Раскладчик может быть не сконфигурирован. При ручной
if res.Decision.Auto && w.layouter != nil { // перепривязке (force_review) авто-раскладку не делаем — нужно явное
plan := applyOverrides(res.Plan, w.overridesOrNil(ctx, id)) // подтверждение человеком.
overrides := w.overridesOrNil(ctx, id)
forceReview := overrides[ovrForceReview] == "1"
if res.Decision.Auto && !forceReview && w.layouter != nil {
plan := applyOverrides(res.Plan, overrides)
w.transition(ctx, *d, store.StateLinking, "", "") w.transition(ctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil { if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil {
w.log.Warn("recognize: auto-apply failed, left for review", w.log.Warn("recognize: auto-apply failed, left for review",
@@ -195,7 +214,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
if w.layouter == nil { if w.layouter == nil {
return fmt.Errorf("apply: раскладчик не сконфигурирован") return fmt.Errorf("apply: layouter not configured")
} }
d, err := w.store.GetDownload(ctx, id) d, err := w.store.GetDownload(ctx, id)
@@ -203,7 +222,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
return fmt.Errorf("apply: %w", err) return fmt.Errorf("apply: %w", err)
} }
if d.State != store.StateReview && d.State != store.StateDeferred { if d.State != store.StateReview && d.State != store.StateDeferred {
return fmt.Errorf("apply: задача %d в состоянии %s (ожидалось review/deferred)", id, d.State) return fmt.Errorf("apply: download %d is in state %s (expected review/deferred)", id, d.State)
} }
plan, tag, err := w.effectivePlan(ctx, id) plan, tag, err := w.effectivePlan(ctx, id)
@@ -212,11 +231,11 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
} }
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String) t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
if err != nil || !ok { if err != nil || !ok {
return fmt.Errorf("apply: торрент не найден: %v", err) return fmt.Errorf("apply: torrent not found: %v", err)
} }
w.transition(ctx, *d, store.StateLinking, "", "") w.transition(ctx, *d, store.StateLinking, "", "")
if err := w.linkPlan(ctx, d, plan, tag, t.SavePath); err != nil { if err := w.linkPlan(ctx, d, plan, tag, translatePath(t.SavePath, w.cfg.PathMap)); err != nil {
return fmt.Errorf("apply: %w", err) return fmt.Errorf("apply: %w", err)
} }
return nil return nil
@@ -229,7 +248,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag)) links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag))
if err != nil { if err != nil {
w.transition(ctx, *d, store.StateReview, "build", err.Error()) w.transition(ctx, *d, store.StateReview, "build", err.Error())
return fmt.Errorf("построение ссылок: %w", err) return fmt.Errorf("build links: %w", err)
} }
batch := w.newID() batch := w.newID()
@@ -249,7 +268,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
} }
if len(fl) > 0 { if len(fl) > 0 {
if err := w.store.CreateFileLinks(ctx, fl); err != nil { if err := w.store.CreateFileLinks(ctx, fl); err != nil {
return fmt.Errorf("запись ссылок: %w", err) return fmt.Errorf("persist links: %w", err)
} }
} }
@@ -267,11 +286,70 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
return nil return nil
} }
// Relink повторно привязывает откатанную (reverted) или отклонённую
// (cancelled) задачу: возвращает её на распознавание, и поллинг-цикл
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
// перепривязка всегда проходит через ревью с подтверждением (force_review).
// Источник (раздача в qBittorrent) для этого должен быть на месте.
func (w *Worker) Relink(ctx context.Context, id int64) error {
w.mu.Lock()
defer w.mu.Unlock()
d, err := w.store.GetDownload(ctx, id)
if err != nil {
return fmt.Errorf("relink: %w", err)
}
if d.State != store.StateReverted && d.State != store.StateCancelled {
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled)", id, d.State)
}
if !d.Infohash.Valid {
return fmt.Errorf("relink: download %d has no infohash", id)
}
// Раздача должна ещё быть в qBittorrent — без неё распознавать нечего.
if _, ok, terr := w.torrentByInfohash(ctx, d.Infohash.String); terr != nil {
return fmt.Errorf("relink: %w", terr)
} else if !ok {
return fmt.Errorf("relink: торрент не найден в qBittorrent")
}
// Вернуть задачу в активную обработку можно, только если другой активной
// задачи на этот infohash нет (partial unique index по idempotency_key).
active, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
if err != nil {
return fmt.Errorf("relink: %w", err)
}
if active != nil {
return fmt.Errorf("relink: для этого торрента уже есть активная задача #%d", active.ID)
}
// Ручная перепривязка — всегда с подтверждением, без авто-раскладки.
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
return fmt.Errorf("relink: %w", err)
}
w.transition(ctx, *d, store.StateRecognizing, "", "")
w.log.Info("relink: re-recognizing download", "download_id", id, "from", d.State)
return nil
}
// Rerecognize перезапускает распознавание для задачи в review/deferred без
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
// цикл проведёт задачу recognizing → review заново.
func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
w.mu.Lock()
defer w.mu.Unlock()
d, err := w.requireReviewable(ctx, id, "rerecognize")
if err != nil {
return err
}
w.log.Info("review: re-recognizing without hint", "download_id", id)
w.transition(ctx, *d, store.StateRecognizing, "", "")
return nil
}
// Refine добавляет подсказку и отправляет задачу на перераспознавание. // Refine добавляет подсказку и отправляет задачу на перераспознавание.
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error { func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
hint = strings.TrimSpace(hint) hint = strings.TrimSpace(hint)
if hint == "" { if hint == "" {
return fmt.Errorf("refine: пустая подсказка") return fmt.Errorf("refine: empty hint")
} }
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -283,6 +361,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
if err := w.store.AddHint(ctx, id, hint); err != nil { if err := w.store.AddHint(ctx, id, hint); err != nil {
return fmt.Errorf("refine: %w", err) return fmt.Errorf("refine: %w", err)
} }
w.log.Info("review: hint added, re-recognizing", "download_id", id, "hint", hint)
w.transition(ctx, *d, store.StateRecognizing, "", "") w.transition(ctx, *d, store.StateRecognizing, "", "")
return nil return nil
} }
@@ -291,7 +370,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
// — чтобы LLM пересобрал роли файлов под новый тип. // — чтобы LLM пересобрал роли файлов под новый тип.
func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error { func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error {
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) { if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
return fmt.Errorf("set type: недопустимый тип %q", mediaType) return fmt.Errorf("set type: invalid type %q", mediaType)
} }
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -319,7 +398,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error { func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
src = strings.TrimSpace(src) src = strings.TrimSpace(src)
if src == "" { if src == "" {
return fmt.Errorf("ignore: пустой путь") return fmt.Errorf("ignore: empty path")
} }
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -339,6 +418,7 @@ func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil { if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil {
return fmt.Errorf("ignore: %w", err) return fmt.Errorf("ignore: %w", err)
} }
w.log.Info("review: file ignored", "download_id", id, "src", src)
return nil return nil
} }
@@ -352,7 +432,7 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
return fmt.Errorf("defer: %w", err) return fmt.Errorf("defer: %w", err)
} }
if d.State.IsTerminal() { if d.State.IsTerminal() {
return fmt.Errorf("defer: задача %d терминальна (%s)", id, d.State) return fmt.Errorf("defer: download %d is terminal (%s)", id, d.State)
} }
w.transition(ctx, *d, store.StateDeferred, "", "") w.transition(ctx, *d, store.StateDeferred, "", "")
return nil return nil
@@ -364,7 +444,7 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
if w.layouter == nil { if w.layouter == nil {
return fmt.Errorf("undo: раскладчик не сконфигурирован") return fmt.Errorf("undo: layouter not configured")
} }
d, err := w.store.GetDownload(ctx, id) d, err := w.store.GetDownload(ctx, id)
@@ -372,14 +452,14 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
return fmt.Errorf("undo: %w", err) return fmt.Errorf("undo: %w", err)
} }
if d.State != store.StateDone { if d.State != store.StateDone {
return fmt.Errorf("undo: задача %d в состоянии %s (ожидалось done)", id, d.State) return fmt.Errorf("undo: download %d is in state %s (expected done)", id, d.State)
} }
batch, err := w.store.LatestBatchID(ctx, id) batch, err := w.store.LatestBatchID(ctx, id)
if err != nil { if err != nil {
return fmt.Errorf("undo: %w", err) return fmt.Errorf("undo: %w", err)
} }
if batch == "" { if batch == "" {
return fmt.Errorf("undo: нечего откатывать") return fmt.Errorf("undo: nothing to revert")
} }
rows, err := w.store.ListFileLinksByBatch(ctx, batch) rows, err := w.store.ListFileLinksByBatch(ctx, batch)
if err != nil { if err != nil {
@@ -408,11 +488,102 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
return nil, fmt.Errorf("%s: %w", op, err) return nil, fmt.Errorf("%s: %w", op, err)
} }
if d.State != store.StateReview && d.State != store.StateDeferred { if d.State != store.StateReview && d.State != store.StateDeferred {
return nil, fmt.Errorf("%s: задача %d в состоянии %s (ожидалось review/deferred)", op, id, d.State) return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred)", op, id, d.State)
} }
return d, nil return d, nil
} }
// --- Выбор базы метаданных (пиннинг; остаёмся в review, применяет человек) ---
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
// человек подтвердит «Применить».
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) error {
w.mu.Lock()
defer w.mu.Unlock()
if _, err := w.requireReviewable(ctx, id, "choose candidate"); err != nil {
return err
}
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
cand, err := w.store.GetCandidate(ctx, candidateID)
if err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
return fmt.Errorf("choose candidate: candidate %d does not belong to the current recognition", candidateID)
}
pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID}
if cand.Title.Valid && cand.Title.String != "" {
pins[ovrTitle] = cand.Title.String
}
if cand.Year.Valid {
pins[ovrYear] = strconv.FormatInt(cand.Year.Int64, 10)
}
for field, value := range pins {
if err := w.store.SetOverride(ctx, id, field, value); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
}
if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
w.log.Info("review: candidate chosen",
"download_id", id, "provider", cand.Provider, "provider_id", cand.ProviderID)
return nil
}
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, providerID string) error {
provider = strings.TrimSpace(strings.ToLower(provider))
providerID = strings.TrimSpace(providerID)
switch provider {
case "tmdb", "tvdb", "imdb":
default:
return fmt.Errorf("set provider: invalid provider %q (tmdb/tvdb/imdb)", provider)
}
if providerID == "" {
return fmt.Errorf("set provider: empty id")
}
w.mu.Lock()
defer w.mu.Unlock()
if _, err := w.requireReviewable(ctx, id, "set provider"); err != nil {
return err
}
if err := w.store.SetOverride(ctx, id, ovrProvider, provider); err != nil {
return fmt.Errorf("set provider: %w", err)
}
if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil {
return fmt.Errorf("set provider: %w", err)
}
w.log.Info("review: provider set manually",
"download_id", id, "provider", provider, "provider_id", providerID)
return nil
}
// ClearProvider — «без базы»: снимает матч (тег папки не ставится).
func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
w.mu.Lock()
defer w.mu.Unlock()
if _, err := w.requireReviewable(ctx, id, "clear provider"); err != nil {
return err
}
if err := w.store.SetOverride(ctx, id, ovrProvider, "none"); err != nil {
return fmt.Errorf("clear provider: %w", err)
}
if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil {
return fmt.Errorf("clear provider: %w", err)
}
w.log.Info("review: provider cleared (no metadata base)", "download_id", id)
return nil
}
// --- Данные для экрана ревью --- // --- Данные для экрана ревью ---
// ReviewData — всё, что нужно транспорту для отрисовки ревью. // ReviewData — всё, что нужно транспорту для отрисовки ревью.
@@ -421,6 +592,9 @@ type ReviewData struct {
Recognition *store.Recognition Recognition *store.Recognition
Plan recognize.Plan // эффективный (с применёнными правками) Plan recognize.Plan // эффективный (с применёнными правками)
Preview []layout.Link // целевые пути (Src — относительный, для показа) Preview []layout.Link // целевые пути (Src — относительный, для показа)
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
Provider string // эффективный провайдер (с учётом выбора)
ProviderID string // эффективный id в базе
Hints []string Hints []string
Overrides map[string]string Overrides map[string]string
} }
@@ -444,18 +618,35 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
return nil, fmt.Errorf("review data: %w", err) return nil, fmt.Errorf("review data: %w", err)
} }
rd := &ReviewData{Download: *d, Recognition: rec, Hints: hints, Overrides: overrides} prov, pid := effectiveProvider(rec, overrides)
rd := &ReviewData{
Download: *d, Recognition: rec, Hints: hints, Overrides: overrides,
Provider: prov, ProviderID: pid,
}
if rec != nil {
if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil {
rd.Candidates = cands
} else {
w.log.Debug("review data: list candidates failed (skipped)",
"download_id", id, "err", cerr)
}
}
if rec != nil && rec.Plan.Valid { if rec != nil && rec.Plan.Valid {
var plan recognize.Plan var plan recognize.Plan
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err == nil { if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
w.log.Warn("review data: unmarshal plan failed", "download_id", id, "err", err)
} else {
plan = applyOverrides(plan, overrides) plan = applyOverrides(plan, overrides)
rd.Plan = plan rd.Plan = plan
// Превью строим по относительным путям с provider-тегом; ошибку // Превью строим по относительным путям с provider-тегом; ошибку
// игнорируем — просто покажем причины без превью. // логируем на Debug — просто покажем причины без превью.
if w.layouter != nil { if w.layouter != nil {
tag := providerTag(rec.Provider.String, rec.ProviderID.String) tag := providerTag(prov, pid)
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil { if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
rd.Preview = links rd.Preview = links
} else {
w.log.Debug("review data: build preview failed (skipped)",
"download_id", id, "err", lerr)
} }
} }
} }
@@ -471,28 +662,37 @@ func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, s
return recognize.Plan{}, "", err return recognize.Plan{}, "", err
} }
if rec == nil || !rec.Plan.Valid { if rec == nil || !rec.Plan.Valid {
return recognize.Plan{}, "", fmt.Errorf("нет плана распознавания") return recognize.Plan{}, "", fmt.Errorf("no recognition plan")
} }
var plan recognize.Plan var plan recognize.Plan
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil { if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil {
return recognize.Plan{}, "", fmt.Errorf("разбор плана: %w", err) return recognize.Plan{}, "", fmt.Errorf("parse plan: %w", err)
} }
overrides, err := w.store.ListOverrides(ctx, id) overrides, err := w.store.ListOverrides(ctx, id)
if err != nil { if err != nil {
return recognize.Plan{}, "", err return recognize.Plan{}, "", err
} }
tag := providerTag(rec.Provider.String, rec.ProviderID.String) prov, pid := effectiveProvider(rec, overrides)
return applyOverrides(plan, overrides), tag, nil return applyOverrides(plan, overrides), providerTag(prov, pid), nil
} }
// --- Хелперы преобразования --- // --- Хелперы преобразования ---
// applyOverrides применяет ручные правки к плану: форсит тип и помечает // applyOverrides применяет ручные правки к плану: форсит тип, каноническое
// игнорируемые файлы ролью ignore (их раскладка пропустит). // имя/год (из выбранного кандидата базы) и помечает игнорируемые файлы ролью
// ignore (их раскладка пропустит).
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan { func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) { if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) {
plan.Type = recognize.MediaType(mt) plan.Type = recognize.MediaType(mt)
} }
if t := overrides[ovrTitle]; t != "" {
plan.Title = t
}
if y := overrides[ovrYear]; y != "" {
if year, err := strconv.Atoi(y); err == nil {
plan.Year = year
}
}
ignored := parseIgnored(overrides[ovrIgnoredFiles]) ignored := parseIgnored(overrides[ovrIgnoredFiles])
if len(ignored) > 0 { if len(ignored) > 0 {
for i := range plan.Files { for i := range plan.Files {
@@ -504,6 +704,48 @@ func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.
return plan return plan
} }
// effectiveProvider возвращает провайдера и id для тега папки с учётом
// ручного выбора: запиненный override перекрывает распознанный матч.
// override "none" означает явный отказ от базы.
func effectiveProvider(rec *store.Recognition, overrides map[string]string) (provider, id string) {
if p, ok := overrides[ovrProvider]; ok {
return p, overrides[ovrProviderID]
}
if rec != nil {
return rec.Provider.String, rec.ProviderID.String
}
return "", ""
}
// toStoreCandidates переводит кандидатов распознавания в строки БД,
// подставляя тег-предпочтительный provider/id (внешний из TVMaze и т.п.).
func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.MetadataCandidate {
out := make([]store.MetadataCandidate, 0, len(cands))
for _, c := range cands {
prov, id := recognize.CandidateTag(c)
mc := store.MetadataCandidate{
RecognitionID: recognitionID,
Provider: prov,
ProviderID: id,
Title: store.NullString(c.Title),
}
if c.Year != 0 {
mc.Year = sql.NullInt64{Int64: int64(c.Year), Valid: true}
}
out = append(out, mc)
}
return out
}
// ProviderTag — экспорт providerTag для диагностических команд (CLI
// `jellybit recognize --dry-run`).
func ProviderTag(provider, id string) string { return providerTag(provider, id) }
// ToLayoutPlan — экспорт toLayoutPlan для диагностических команд.
func ToLayoutPlan(p recognize.Plan, srcPrefix, providerTag string) layout.Plan {
return toLayoutPlan(p, srcPrefix, providerTag)
}
// providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…" // providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
// / "tvdbid-…". Пустой id (нет матча) → пустой тег. // / "tvdbid-…". Пустой id (нет матча) → пустой тег.
func providerTag(provider, id string) string { func providerTag(provider, id string) string {
@@ -515,6 +757,8 @@ func providerTag(provider, id string) string {
return "tmdbid-" + id return "tmdbid-" + id
case "tvdb": case "tvdb":
return "tvdbid-" + id return "tvdbid-" + id
case "imdb":
return "imdbid-" + id
default: default:
return "" return ""
} }
@@ -563,9 +807,12 @@ func mapRole(r recognize.FileRole) (layout.Role, bool) {
} }
} }
// torrentByInfohash ищет торрент категории по infohash (v1/v2/hash). // torrentByInfohash ищет торрент по infohash (v1/v2/hash). Листаем ВСЕ
// торренты (а не только свою категорию): раздача могла быть усыновлена по
// тегу и иметь чужую/пустую категорию — фильтр по категории её бы потерял
// (как и в Poll, см. там же).
func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) { func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) {
torrents, err := w.qbt.Torrents(ctx, w.cfg.Category) torrents, err := w.qbt.Torrents(ctx, "")
if err != nil { if err != nil {
return qbt.Torrent{}, false, err return qbt.Torrent{}, false, err
} }
+476 -2
View File
@@ -2,19 +2,219 @@ package worker
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"io" "io"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/metadata"
"git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
) )
// recordingNotifier ловит события пинга (Notify асинхронен — через канал).
type notifyEvent struct {
id int64
ev NotifyEvent
}
type recordingNotifier struct{ ch chan notifyEvent }
func (n *recordingNotifier) Notify(_ context.Context, id int64, ev NotifyEvent) {
n.ch <- notifyEvent{id, ev}
}
func waitNotify(t *testing.T, n *recordingNotifier) notifyEvent {
t.Helper()
select {
case e := <-n.ch:
return e
case <-time.After(2 * time.Second):
t.Fatal("пинг не пришёл")
return notifyEvent{}
}
}
func TestNotifier_FiresOnReview(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1))
qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}},
}
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
w.SetNotifier(n)
w.recognizeOne(context.Background(), 1)
e := waitNotify(t, n)
if e.id != 1 || e.ev != EventReview {
t.Errorf("event = %+v, want {1 review}", e)
}
}
func TestNotifier_FiresOnDone(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
f.w.SetNotifier(n)
if err := f.w.Apply(context.Background(), 1); err != nil {
t.Fatalf("Apply: %v", err)
}
e := waitNotify(t, n)
if e.id != 1 || e.ev != EventDone {
t.Errorf("event = %+v, want {1 done}", e)
}
}
// recordingScanner ловит вызовы пересканирования Jellyfin (RefreshLibraries
// асинхронен — через канал).
type recordingScanner struct{ ch chan struct{} }
func (s *recordingScanner) RefreshLibraries(_ context.Context) error {
s.ch <- struct{}{}
return nil
}
func TestScanner_FiresOnDone(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
s := &recordingScanner{ch: make(chan struct{}, 4)}
f.w.SetScanner(s)
if err := f.w.Apply(context.Background(), 1); err != nil {
t.Fatalf("Apply: %v", err)
}
select {
case <-s.ch:
case <-time.After(2 * time.Second):
t.Fatal("пересканирование Jellyfin не запустилось")
}
}
func revertedDownload(id int64) *store.Download {
d := completedDownload(id)
d.State = store.StateReverted
return d
}
func TestRelink_RevertedToRecognizing(t *testing.T) {
st := newMemStore()
st.put(revertedDownload(1))
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
if err := w.Relink(context.Background(), 1); err != nil {
t.Fatalf("Relink: %v", err)
}
if st.downloads[1].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
}
if st.overrides[1][ovrForceReview] != "1" {
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview])
}
}
func TestRelink_CancelledToRecognizing(t *testing.T) {
st := newMemStore()
d := revertedDownload(1)
d.State = store.StateCancelled
st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
if err := w.Relink(context.Background(), 1); err != nil {
t.Fatalf("Relink: %v", err)
}
if st.downloads[1].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
}
if st.overrides[1][ovrForceReview] != "1" {
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview])
}
}
func TestRelink_RejectsActiveState(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1)) // не reverted/cancelled
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Relink(context.Background(), 1); err == nil {
t.Fatal("ожидали ошибку для не-reverted/cancelled задачи, получили nil")
}
}
func TestRerecognize_ReviewToRecognizing(t *testing.T) {
st := newMemStore()
d := completedDownload(1)
d.State = store.StateReview
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
if err := w.Rerecognize(context.Background(), 1); err != nil {
t.Fatalf("Rerecognize: %v", err)
}
if st.downloads[1].State != store.StateRecognizing {
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
}
}
func TestRerecognize_RejectsNonReview(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1)) // completed, не review/deferred
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
if err := w.Rerecognize(context.Background(), 1); err == nil {
t.Fatal("ожидали ошибку для не-review задачи, получили nil")
}
}
func TestRelink_TorrentMissing(t *testing.T) {
st := newMemStore()
st.put(revertedDownload(1))
qb := &fakeQbt{torrents: nil} // раздачи в qBittorrent нет
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.Relink(context.Background(), 1); err == nil {
t.Fatal("ожидали ошибку при отсутствии торрента, получили nil")
}
if st.downloads[1].State != store.StateReverted {
t.Errorf("state = %q, want reverted (без изменений)", st.downloads[1].State)
}
}
// TestRelink_ForceReviewSkipsAuto проверяет, что после перепривязки даже
// уверенный матч не уходит в авто-раскладку, а ждёт подтверждения в review.
func TestRelink_ForceReviewSkipsAuto(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
// Готовим состояние «как после Relink»: reverted, force_review выставлен.
f.st.downloads[1].State = store.StateReverted
_ = f.st.SetOverride(context.Background(), 1, ovrForceReview, "1")
auto := seriesResult()
auto.Decision.Auto = true
auto.Match = &recognize.Match{Provider: "tvdb", ProviderID: "42"}
f.w.recognizer = &fakeRecognizer{result: auto}
if err := f.w.Relink(context.Background(), 1); err != nil {
t.Fatalf("Relink: %v", err)
}
f.w.recognizeOne(context.Background(), 1)
if f.st.downloads[1].State != store.StateReview {
t.Fatalf("state = %q, want review (авто-раскладка не должна сработать)", f.st.downloads[1].State)
}
if len(f.st.links) != 0 {
t.Errorf("file_links = %d, want 0 (ничего не линковали)", len(f.st.links))
}
}
// memStore — полноценный in-memory store для тестов Ф3. // memStore — полноценный in-memory store для тестов Ф3.
type memStore struct { type memStore struct {
downloads map[int64]*store.Download downloads map[int64]*store.Download
@@ -22,6 +222,7 @@ type memStore struct {
hints map[int64][]string hints map[int64][]string
overrides map[int64]map[string]string overrides map[int64]map[string]string
links []store.FileLink links []store.FileLink
candidates []store.MetadataCandidate
} }
func newMemStore() *memStore { func newMemStore() *memStore {
@@ -46,6 +247,33 @@ func (m *memStore) ListDownloadsByState(_ context.Context, states ...store.State
return out, nil return out, nil
} }
func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
for _, d := range m.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash {
return true, nil
}
}
return false, nil
}
func (m *memStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
for _, d := range m.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
cp := *d
return &cp, nil
}
}
return nil, nil
}
func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(m.downloads) + 1)
cp := *d
cp.ID = id
m.downloads[id] = &cp
return id, nil
}
func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, error) { func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
d, ok := m.downloads[id] d, ok := m.downloads[id]
if !ok { if !ok {
@@ -143,6 +371,40 @@ func (m *memStore) DeleteFileLinksByBatch(_ context.Context, batch string) error
return nil return nil
} }
func (m *memStore) CreateCandidates(_ context.Context, cands []store.MetadataCandidate) error {
for _, c := range cands {
c.ID = int64(len(m.candidates) + 1)
m.candidates = append(m.candidates, c)
}
return nil
}
func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID int64) ([]store.MetadataCandidate, error) {
var out []store.MetadataCandidate
for _, c := range m.candidates {
if c.RecognitionID == recID {
out = append(out, c)
}
}
return out, nil
}
func (m *memStore) GetCandidate(_ context.Context, id int64) (*store.MetadataCandidate, error) {
for i := range m.candidates {
if m.candidates[i].ID == id {
cp := m.candidates[i]
return &cp, nil
}
}
return nil, nil
}
func (m *memStore) SetCandidateChosen(_ context.Context, recID, id int64) error {
for i := range m.candidates {
if m.candidates[i].RecognitionID == recID {
m.candidates[i].Chosen = m.candidates[i].ID == id
}
}
return nil
}
func jsonMarshal(v any) (string, error) { func jsonMarshal(v any) (string, error) {
b, err := json.Marshal(v) b, err := json.Marshal(v)
return string(b), err return string(b), err
@@ -234,6 +496,42 @@ func TestRecognizeOne_CompletedToReview(t *testing.T) {
} }
} }
// TestRecognizeOne_FindsTagAdoptedTorrent — регрессия: раздача, усыновлённая
// по тегу, имеет чужую (или пустую) категорию. Поиск по infohash при
// распознавании обязан её найти; раньше фильтр по w.cfg.Category её терял и
// распознавание падало с «torrent not found in qBittorrent».
func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1))
qb := &fakeQbt{
torrents: []qbt.Torrent{{
Hash: ihTest, Name: "ThePitt", SavePath: "/d",
Category: "movies", Tags: "jellybit", // тег наш, категория чужая
}},
files: []qbt.File{{Name: "ThePitt/e1.mkv", Size: 100}, {Name: "ThePitt/e2.mkv", Size: 100}},
}
rec := &fakeRecognizer{result: seriesResult()}
w := testWorkerWith(st, qb, rec, nil)
w.recognizeOne(context.Background(), 1)
if st.downloads[1].State != store.StateReview {
t.Fatalf("state = %q, want review", st.downloads[1].State)
}
// Recognizer вернул бы Title="Show" только если торрент найден по infohash;
// при потере (фильтр по категории) был бы пустой план с причиной «not found».
cur, _ := st.GetCurrentRecognition(context.Background(), 1)
if cur == nil || cur.Title.String != "Show" {
t.Fatalf("recognizer did not run on found torrent (title=%q): torrent must be found by infohash despite foreign category",
func() string {
if cur == nil {
return "<nil>"
}
return cur.Title.String
}())
}
}
func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) { func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) {
st := newMemStore() st := newMemStore()
st.put(completedDownload(1)) st.put(completedDownload(1))
@@ -381,7 +679,7 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
t.Fatal(err) t.Fatal(err)
} }
} }
lay, err := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}) lay, err := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -543,7 +841,7 @@ func TestRecognizeOne_AutoApplies(t *testing.T) {
_ = os.MkdirAll(filepath.Dir(p), 0o755) _ = os.MkdirAll(filepath.Dir(p), 0o755)
_ = os.WriteFile(p, []byte("x"), 0o644) _ = os.WriteFile(p, []byte("x"), 0o644)
} }
lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}) lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}, nil)
st := newMemStore() st := newMemStore()
st.put(completedDownload(1)) st.put(completedDownload(1))
@@ -591,6 +889,7 @@ func TestProviderTag(t *testing.T) {
cases := []struct{ provider, id, want string }{ cases := []struct{ provider, id, want string }{
{"tmdb", "603", "tmdbid-603"}, {"tmdb", "603", "tmdbid-603"},
{"tvdb", "123", "tvdbid-123"}, {"tvdb", "123", "tvdbid-123"},
{"imdb", "tt2802850", "imdbid-tt2802850"},
{"none", "", ""}, {"none", "", ""},
{"tmdb", "", ""}, {"tmdb", "", ""},
{"weird", "1", ""}, {"weird", "1", ""},
@@ -602,6 +901,143 @@ func TestProviderTag(t *testing.T) {
} }
} }
// reviewWithCandidate готовит memStore: задача в review, одна попытка
// распознавания с одним кандидатом базы.
func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore) {
t.Helper()
st := newMemStore()
d := completedDownload(1)
d.State = store.StateReview
st.put(d)
planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaSeries, Title: "Догадка", Year: 2000})
st.recs = append(st.recs, &store.Recognition{
ID: 1, DownloadID: 1, IsCurrent: true, Plan: store.NullString(string(planJSON)),
Provider: store.NullString("none"),
})
cand.RecognitionID = 1
_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
return w, st
}
func TestRecognizeOne_PersistsCandidates(t *testing.T) {
st := newMemStore()
st.put(completedDownload(1))
qb := &fakeQbt{
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
files: []qbt.File{{Name: "e1.mkv", Size: 1}},
}
res := seriesResult()
res.Candidates = []metadata.Candidate{
{Provider: "tvmaze", ID: "1", Title: "Show A", Year: 2006, TagProvider: "tvdb", TagID: "269613"},
{Provider: "tvmaze", ID: "2", Title: "Show B", Year: 2007},
}
w := testWorkerWith(st, qb, &fakeRecognizer{result: res}, nil)
w.recognizeOne(context.Background(), 1)
if len(st.candidates) != 2 {
t.Fatalf("candidates = %d, want 2", len(st.candidates))
}
// Тег-предпочтительный provider/id сохранён (TVMaze → tvdb).
if st.candidates[0].Provider != "tvdb" || st.candidates[0].ProviderID != "269613" {
t.Errorf("candidate[0] = %+v", st.candidates[0])
}
}
func TestChooseCandidate_PinsOverrides(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613",
Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true},
})
candID := st.candidates[0].ID
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
ov := st.overrides[1]
if ov[ovrProvider] != "tvdb" || ov[ovrProviderID] != "269613" ||
ov[ovrTitle] != "Fargo" || ov[ovrYear] != "2014" {
t.Errorf("overrides = %v", ov)
}
if !st.candidates[0].Chosen {
t.Error("кандидат не помечен выбранным")
}
// Эффективный план берёт каноническое имя/год и тег [tvdbid-...].
plan, tag, err := w.effectivePlan(context.Background(), 1)
if err != nil {
t.Fatalf("effectivePlan: %v", err)
}
if plan.Title != "Fargo" || plan.Year != 2014 {
t.Errorf("plan = %q (%d)", plan.Title, plan.Year)
}
if tag != "tvdbid-269613" {
t.Errorf("tag = %q", tag)
}
}
func TestChooseCandidate_RejectsForeign(t *testing.T) {
w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
if err := w.ChooseCandidate(context.Background(), 1, 999); err == nil {
t.Error("чужой кандидат должен отклоняться")
}
}
func TestSetProviderID(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
if err := w.SetProviderID(context.Background(), 1, "TMDB", " 603 "); err != nil {
t.Fatalf("SetProviderID: %v", err)
}
if st.overrides[1][ovrProvider] != "tmdb" || st.overrides[1][ovrProviderID] != "603" {
t.Errorf("overrides = %v", st.overrides[1])
}
if err := w.SetProviderID(context.Background(), 1, "kinopoisk", "1"); err == nil {
t.Error("недопустимый провайдер должен отклоняться")
}
if err := w.SetProviderID(context.Background(), 1, "tmdb", ""); err == nil {
t.Error("пустой id должен отклоняться")
}
}
func TestClearProvider(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
_ = st.SetOverride(context.Background(), 1, ovrProvider, "tvdb")
if err := w.ClearProvider(context.Background(), 1); err != nil {
t.Fatalf("ClearProvider: %v", err)
}
if st.overrides[1][ovrProvider] != "none" {
t.Errorf("provider override = %q, want none", st.overrides[1][ovrProvider])
}
// «Без базы» → пустой тег.
_, tag, _ := w.effectivePlan(context.Background(), 1)
if tag != "" {
t.Errorf("tag = %q, want empty", tag)
}
}
func TestReviewData_IncludesCandidates(t *testing.T) {
w, st := reviewWithCandidate(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
})
candID := st.candidates[0].ID
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil {
t.Fatal(err)
}
rd, err := w.ReviewData(context.Background(), 1)
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
if len(rd.Candidates) != 1 {
t.Fatalf("candidates = %d", len(rd.Candidates))
}
if rd.Provider != "tvdb" || rd.ProviderID != "269613" {
t.Errorf("eff provider = %s/%s", rd.Provider, rd.ProviderID)
}
if rd.Plan.Title != "Fargo" {
t.Errorf("plan title = %q", rd.Plan.Title)
}
}
func TestToLayoutPlan(t *testing.T) { func TestToLayoutPlan(t *testing.T) {
s, e := 1, 3 s, e := 1, 3
plan := recognize.Plan{ plan := recognize.Plan{
@@ -625,3 +1061,41 @@ func TestToLayoutPlan(t *testing.T) {
t.Errorf("provider tag = %q", lp.ProviderTag) t.Errorf("provider tag = %q", lp.ProviderTag)
} }
} }
// TestToLayoutPlan_SrcPrefixIsSavePath фиксирует семантику префикса: имена
// файлов из qBittorrent /torrents/files относительны save_path и уже содержат
// корневую папку для многофайловых раздач. Префикс — save_path, а не
// content_path (иначе корневая папка удвоилась бы, а однофайловая раздача
// получила бы путь под самим файлом). Это регрессионный страж против правки
// префикса на content_path.
func TestToLayoutPlan_SrcPrefixIsSavePath(t *testing.T) {
const savePath = "/srv/media/downloads"
s, e := 1, 1
cases := []struct {
name string
src string
want string
}{
// Многофайловая раздача: имя включает корневую папку торрента.
{"multi-file", "Show.S01/e1.mkv", filepath.Join(savePath, "Show.S01/e1.mkv")},
// Однофайловая раздача: имя — просто файл (content_path = save_path+файл).
{"single-file", "movie.mkv", filepath.Join(savePath, "movie.mkv")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
plan := recognize.Plan{
Type: recognize.MediaMovie, Title: "X", Year: 2020,
Files: []recognize.PlanFile{
{Src: tc.src, Role: recognize.RoleMain, Season: &s, Episode: &e},
},
}
lp := toLayoutPlan(plan, savePath, "")
if len(lp.Files) != 1 {
t.Fatalf("want 1 file, got %d", len(lp.Files))
}
if lp.Files[0].Src != tc.want {
t.Errorf("src = %q, want %q", lp.Files[0].Src, tc.want)
}
})
}
}
+70 -1
View File
@@ -31,6 +31,11 @@ type Store interface {
GetDownload(ctx context.Context, id int64) (*store.Download, error) GetDownload(ctx context.Context, id int64) (*store.Download, error)
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
// Discovery (усыновление раздач по категории/тегу).
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
// Ф3: распознавание, ревью, раскладка. // Ф3: распознавание, ревью, раскладка.
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (int64, error) CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (int64, error)
GetCurrentRecognition(ctx context.Context, downloadID int64) (*store.Recognition, error) GetCurrentRecognition(ctx context.Context, downloadID int64) (*store.Recognition, error)
@@ -42,6 +47,12 @@ type Store interface {
LatestBatchID(ctx context.Context, downloadID int64) (string, error) LatestBatchID(ctx context.Context, downloadID int64) (string, error)
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error) ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
DeleteFileLinksByBatch(ctx context.Context, batchID string) error DeleteFileLinksByBatch(ctx context.Context, batchID string) error
// Кандидаты базы метаданных (ручной выбор в review).
CreateCandidates(ctx context.Context, cands []store.MetadataCandidate) error
ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]store.MetadataCandidate, error)
GetCandidate(ctx context.Context, id int64) (*store.MetadataCandidate, error)
SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error
} }
// QBittorrent — нужная worker часть клиента qBittorrent. // QBittorrent — нужная worker часть клиента qBittorrent.
@@ -63,10 +74,32 @@ type Layouter interface {
Undo(ctx context.Context, links []layout.Link) (int, error) Undo(ctx context.Context, links []layout.Link) (int, error)
} }
// NotifyEvent — повод позвать пользователя.
type NotifyEvent string
const (
EventReview NotifyEvent = "review" // задача ждёт подтверждения
EventDone NotifyEvent = "done" // раскладка завершена
)
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
type Notifier interface {
Notify(ctx context.Context, downloadID int64, event NotifyEvent)
}
// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается
// неблокирующе после успешной раскладки, чтобы новые файлы быстрее появились
// в проигрывателе.
type Scanner interface {
RefreshLibraries(ctx context.Context) error
}
// Config — параметры воркера. // Config — параметры воркера.
type Config struct { type Config struct {
Category string Category string
Tag string // метка для усыновления существующих раздач (discovery)
SavePath string SavePath string
PathMap map[string]string // трансляция save_path qBit → хост-путь (обычно пусто)
PollInterval time.Duration PollInterval time.Duration
StuckAfter time.Duration // stalledDL дольше → stuck StuckAfter time.Duration // stalledDL дольше → stuck
MagnetTimeout time.Duration // metaDL дольше → failed MagnetTimeout time.Duration // metaDL дольше → failed
@@ -84,8 +117,16 @@ type Worker struct {
mu sync.Mutex // сериализует переходы (поллинг + команды) mu sync.Mutex // сериализует переходы (поллинг + команды)
now func() time.Time // подменяется в тестах now func() time.Time // подменяется в тестах
newID func() string // генератор apply_batch_id (подменяется в тестах) newID func() string // генератор apply_batch_id (подменяется в тестах)
notifier Notifier // опц. исходящие пинги
scanner Scanner // опц. пересканирование Jellyfin
} }
// SetNotifier подключает исходящие пинги (до запуска Run).
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
// SetScanner подключает пересканирование Jellyfin (до запуска Run).
func (w *Worker) SetScanner(s Scanner) { w.scanner = s }
// New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней // New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней
// распознавания и раскладки) — тогда completed-задачи не двигаются дальше. // распознавания и раскладки) — тогда completed-задачи не двигаются дальше.
func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker { func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker {
@@ -135,8 +176,10 @@ func (w *Worker) pollOnce(ctx context.Context) {
} }
// Poll сверяет активные задачи с состоянием qBittorrent и двигает их. // Poll сверяет активные задачи с состоянием qBittorrent и двигает их.
// Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и
// усыновлённые по тегу раздачи, а discovery — увидел новые.
func (w *Worker) Poll(ctx context.Context) error { func (w *Worker) Poll(ctx context.Context) error {
torrents, err := w.qbt.Torrents(ctx, w.cfg.Category) torrents, err := w.qbt.Torrents(ctx, "")
if err != nil { if err != nil {
return fmt.Errorf("poll: list torrents: %w", err) return fmt.Errorf("poll: list torrents: %w", err)
} }
@@ -152,6 +195,9 @@ func (w *Worker) Poll(ctx context.Context) error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
// Усыновляем новые раздачи с нашей категорией/тегом до reconcile.
w.discover(ctx, torrents)
active, err := w.store.ListDownloadsByState(ctx, store.StateDownloading) active, err := w.store.ListDownloadsByState(ctx, store.StateDownloading)
if err != nil { if err != nil {
return fmt.Errorf("poll: list active: %w", err) return fmt.Errorf("poll: list active: %w", err)
@@ -215,6 +261,29 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
} }
w.log.Info("state transition", w.log.Info("state transition",
"download_id", d.ID, "from", d.State, "to", state, "code", code) "download_id", d.ID, "from", d.State, "to", state, "code", code)
// Пинги — неблокирующе и в отдельном контексте: вызов уходит в сеть, а
// мы под w.mu (Notify читает состояние уже после освобождения замка).
if w.notifier != nil {
switch state {
case store.StateReview:
go w.notifier.Notify(context.Background(), d.ID, EventReview)
case store.StateDone:
go w.notifier.Notify(context.Background(), d.ID, EventDone)
}
}
// Раскладка завершена — просим Jellyfin пересканировать библиотеку, чтобы
// новые файлы быстрее появились в проигрывателе. Тоже неблокирующе и вне
// w.mu; недоступность Jellyfin не влияет на состояние задачи.
if w.scanner != nil && state == store.StateDone {
id := d.ID
go func() {
if err := w.scanner.RefreshLibraries(context.Background()); err != nil {
w.log.Warn("jellyfin: library refresh failed", "download_id", id, "err", err)
}
}()
}
} }
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает // Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
+51 -1
View File
@@ -51,6 +51,33 @@ func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, e
return &cp, nil return &cp, nil
} }
func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
for _, d := range f.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash {
return true, nil
}
}
return false, nil
}
func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
for _, d := range f.downloads {
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
cp := *d
return &cp, nil
}
}
return nil, nil
}
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
id := int64(len(f.downloads) + 1)
cp := *d
cp.ID = id
f.downloads[id] = &cp
return id, nil
}
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error { func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
d, ok := f.downloads[id] d, ok := f.downloads[id]
if !ok { if !ok {
@@ -83,6 +110,16 @@ func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.F
return nil, nil return nil, nil
} }
func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error { return nil } func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error { return nil }
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
return nil
}
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ int64) ([]store.MetadataCandidate, error) {
return nil, nil
}
func (f *fakeStore) GetCandidate(_ context.Context, _ int64) (*store.MetadataCandidate, error) {
return nil, nil
}
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ int64) error { return nil }
type fakeQbt struct { type fakeQbt struct {
torrents []qbt.Torrent torrents []qbt.Torrent
@@ -90,8 +127,21 @@ type fakeQbt struct {
files []qbt.File files []qbt.File
} }
func (f *fakeQbt) Torrents(_ context.Context, _ string) ([]qbt.Torrent, error) { // Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
// только торренты этой категории (как реальный qBittorrent). Это важно для
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
// теряться при поиске по infohash.
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
if category == "" {
return f.torrents, nil return f.torrents, nil
}
var out []qbt.Torrent
for _, t := range f.torrents {
if t.Category == category {
out = append(out, t)
}
}
return out, nil
} }
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error { func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
+5
View File
@@ -69,6 +69,11 @@
<button type="submit">Откатить</button> <button type="submit">Откатить</button>
</form> </form>
{{end}} {{end}}
{{if .Relinkable}}
<form method="post" action="/ui/downloads/{{.ID}}/relink">
<button type="submit">Привязать заново</button>
</form>
{{end}}
{{if not .Terminal}} {{if not .Terminal}}
<form method="post" action="/ui/downloads/{{.ID}}/cancel"> <form method="post" action="/ui/downloads/{{.ID}}/cancel">
<button type="submit">Отклонить</button> <button type="submit">Отклонить</button>
+48 -1
View File
@@ -57,7 +57,7 @@
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b> Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}} · Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}}
{{if .Year}}· Год: <b>{{.Year}}</b>{{end}} {{if .Year}}· Год: <b>{{.Year}}</b>{{end}}
{{if .Provider}}· База: <b>{{.Provider}}</b> {{.ProviderID}}{{end}} {{if .Provider}}· База: <b>{{.Provider}}</b> {{.ProviderID}}{{else if .NoBase}}· База: <b>без базы</b>{{end}}
</p> </p>
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type"> <form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
Переключить тип: Переключить тип:
@@ -67,6 +67,49 @@
</form> </form>
</section> </section>
<section>
<strong>База метаданных</strong>
{{if .Provider}}<p>Выбрано: <b>{{.Provider}}</b> {{.ProviderID}}</p>
{{else if .NoBase}}<p>Выбрано: <b>без базы</b> (тег папки не ставится)</p>
{{else}}<p><small>Матч не подтверждён — выберите кандидата, введите id или «без базы».</small></p>{{end}}
{{if .Candidates}}
<table>
<thead><tr><th>провайдер</th><th>название</th><th>год</th><th>id</th><th></th></tr></thead>
<tbody>
{{range .Candidates}}
<tr>
<td>{{.Provider}}</td>
<td>{{.Title}}</td>
<td>{{if .Year}}{{.Year}}{{end}}</td>
<td class="src">{{.ProviderID}}</td>
<td>
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
<input type="hidden" name="candidate_id" value="{{.ID}}">
<button type="submit" {{if .Chosen}}disabled{{end}}>{{if .Chosen}}выбрано{{else}}выбрать{{end}}</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
<form class="row" method="post" action="/ui/downloads/{{.ID}}/provider">
Вручную:
<select name="provider">
<option value="tvdb">tvdb</option>
<option value="tmdb">tmdb</option>
<option value="imdb">imdb</option>
</select>
<input type="text" name="provider_id" placeholder="id (напр. 269613)" required>
<button type="submit">задать id</button>
</form>
<form method="post" action="/ui/downloads/{{.ID}}/nobase" style="margin-top:.4rem">
<button type="submit">Без базы</button>
</form>
</section>
<section> <section>
<strong>Файлы → роль</strong> <strong>Файлы → роль</strong>
<table> <table>
@@ -107,6 +150,10 @@
<p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p> <p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p>
<button type="submit">🔁 Уточнить</button> <button type="submit">🔁 Уточнить</button>
</form> </form>
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:.4rem">
<button type="submit">🔄 Распознать заново</button>
<small>(без новой подсказки — по уже накопленному контексту)</small>
</form>
{{if .Hints}} {{if .Hints}}
<p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p> <p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p>
{{end}} {{end}}