Compare commits

...

10 Commits

61 changed files with 3438 additions and 232 deletions
+6
View File
@@ -8,4 +8,10 @@ FROM gcr.io/distroless/static-debian12
COPY jellybit /usr/local/bin/jellybit COPY jellybit /usr/local/bin/jellybit
EXPOSE 8080 EXPOSE 8080
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
# /data/config.toml). compose может переопределить параметры healthcheck.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["/usr/local/bin/jellybit", "healthcheck"]
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/data/config.toml"] ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/data/config.toml"]
+38 -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,8 +18,8 @@ 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. По именам файлов, контексту и (опц.) базам метаданных определяется
@@ -30,13 +30,20 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
При высокой уверенности раскладка выполняется автоматически, иначе — При высокой уверенности раскладка выполняется автоматически, иначе —
уходит на подтверждение человеку. уходит на подтверждение человеку.
Доступ к внешним сервисам (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 +74,25 @@ 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` (единая
песочница для хардлинков) + data-том с `config.toml`/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", "/data/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("ожидалась ошибка без сервера")
}
}
+8 -2
View File
@@ -2,8 +2,10 @@
// //
// Подкоманды: // Подкоманды:
// //
// 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", "/data/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)
}
}
+73 -9
View File
@@ -5,11 +5,15 @@ 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"
@@ -20,6 +24,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"
) )
@@ -51,7 +56,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 +67,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 +86,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,14 +102,16 @@ 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(),
@@ -124,6 +131,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 +189,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)
} }
+13 -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,11 @@ api_key = ""
proxy = "" proxy = ""
timeout = "10s" timeout = "10s"
[metadata.tvmaze]
enabled = false # без ключа; только сериалы, тег [tvdbid-…] из externals
proxy = ""
timeout = "10s"
[worker] [worker]
poll_interval = "5s" poll_interval = "5s"
stuck_after = "1h" stuck_after = "1h"
@@ -52,10 +57,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"
+12 -8
View File
@@ -36,7 +36,7 @@ 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, htmx) |
@@ -84,9 +84,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/готовности.
@@ -141,7 +142,9 @@ password = ""
category = "jellybit" category = "jellybit"
savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении) savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении)
# Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично, # Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично,
# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся. # поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся:
# самый длинный совпавший префикс save_path (ключ) меняется на хост-префикс
# (значение), совпадение — по границам сегментов. Напр. {"/data" = "/srv/media"}.
path_map = {} path_map = {}
[paths] [paths]
@@ -189,7 +192,7 @@ allowed_user_ids = [] # пусто = запрет всем (fail-closed)
[http] [http]
listen = ":8080" listen = ":8080"
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений trusted_subnets = [] # зарезервировано, ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN)
[log] [log]
level = "info" level = "info"
@@ -214,8 +217,9 @@ format = "json"
`stuck_after``stuck`/`failed`. `stuck_after``stuck`/`failed`.
- **ошибка:** `error`/`missingFiles``failed`. - **ошибка:** `error`/`missingFiles``failed`.
Пути файлов берём из API (`save_path`/`content_path` + относительные Пути файлов берём из API (`save_path` + относительные имена из
имена), не из константы (обычно это уже хост-путь). «Incomplete»-каталог в `/torrents/files`, уже включающие корневую папку торрента), не из
константы (обычно это уже хост-путь). «Incomplete»-каталог в
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
`moving` — дожидаемся окончания переноса и только потом берём финальный `moving` — дожидаемся окончания переноса и только потом берём финальный
+3 -2
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`). Исходный файл остаётся на месте (раздача продолжается),
+5 -3
View File
@@ -11,9 +11,11 @@
- Имя торрента и структура каталогов. - Имя торрента и структура каталогов.
- Список файлов с размерами и расширениями. Абсолютный путь источника - Список файлов с размерами и расширениями. Абсолютный путь источника
восстанавливаем как `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).
+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=
+20 -8
View File
@@ -26,10 +26,15 @@ type Config struct {
// QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок. // QBittorrent — доступ к qBittorrent WebUI и раскладка путей загрузок.
type QBittorrent struct { 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 string `toml:"category"` // Category — категория для добавляемых jellybit раздач (push, savepath).
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"`
} }
@@ -59,11 +64,13 @@ type LLM struct {
// Metadata — внешние базы метаданных (опциональны). // Metadata — внешние базы метаданных (опциональны).
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"`
@@ -88,11 +95,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"`
} }
+4
View File
@@ -87,6 +87,9 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Post("/ui/downloads/{id}/refine", s.handleRefine) r.Post("/ui/downloads/{id}/refine", s.handleRefine)
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)
@@ -262,6 +265,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
} }
+78 -9
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"
@@ -182,14 +183,17 @@ func (e ingestErr) Error() string { return string(e) }
// --- Ревью --- // --- Ревью ---
type fakeReviewer struct { type fakeReviewer struct {
data *worker.ReviewData data *worker.ReviewData
applyErr error applyErr error
refined map[int64]string refined map[int64]string
typed map[int64]string typed map[int64]string
ignored map[int64]string ignored map[int64]string
applied []int64 chosen map[int64]int64
deferred []int64 providerSet map[int64]string
undone []int64 applied []int64
deferred []int64
undone []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 +235,24 @@ 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) 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 +270,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 +301,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{},
+61 -3
View File
@@ -20,6 +20,9 @@ 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
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 +47,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 +59,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 +101,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 +121,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 +151,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
} }
@@ -151,6 +179,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 +212,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 +226,7 @@ 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()) redirectErr(w, r, err.Error())
return return
} }
@@ -186,6 +242,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",
+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)
} }
+62 -19
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}
if mt == metadata.Series {
if counts, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil {
match.SeasonEpisodeCounts = counts
} else {
r.log.Warn("recognize: episode counts failed",
"provider", p.Name(), "id", c.ID, "err", cerr)
}
}
return match
} }
return nil 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 got, err := p.SeasonEpisodeCounts(ctx, c.ID); err == nil {
counts = got
} else {
r.log.Warn("recognize: episode counts failed", "provider", p.Name(), "id", c.ID, "err", err)
}
}
prov, pid := CandidateTag(c)
return &Match{
Provider: prov,
ProviderID: pid,
Title: c.Title,
Year: c.Year,
SeasonEpisodeCounts: counts,
}
}
// 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)
} }
} }
+20 -16
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
@@ -115,12 +115,13 @@ type Match struct {
// Result — итог распознавания. // Result — итог распознавания.
type Result struct { 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,13 +249,15 @@ 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,
Attempts: attempts, Candidates: candidates,
Raw: raw, Attempts: attempts,
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)
}
})
}
}
+211 -31
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,10 @@ 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" // запиненный год
) )
// recognizePending распознаёт завершённые загрузки и перезапускает те, что // recognizePending распознаёт завершённые загрузки и перезапускает те, что
@@ -75,14 +81,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 +109,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,10 +165,17 @@ 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. Раскладчик может быть не сконфигурирован.
@@ -195,7 +209,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 +217,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 +226,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 +243,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 +263,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)
} }
} }
@@ -271,7 +285,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
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 +297,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 +306,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 +334,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 +354,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 +368,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 +380,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 +388,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,19 +424,113 @@ 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 — всё, что нужно транспорту для отрисовки ревью.
type ReviewData struct { type ReviewData struct {
Download store.Download Download store.Download
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 +554,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 +598,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 +640,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 +693,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 ""
} }
+293 -7
View File
@@ -2,26 +2,85 @@ 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)
}
}
// 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
recs []*store.Recognition recs []*store.Recognition
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 +105,23 @@ 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) 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 +219,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
@@ -381,7 +491,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 +653,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 +701,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 +713,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 +873,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)
}
})
}
}
+49 -4
View File
@@ -31,6 +31,10 @@ 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)
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 +46,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 +73,25 @@ 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)
}
// 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
@@ -81,11 +106,15 @@ type Worker struct {
cfg Config cfg Config
log *slog.Logger log *slog.Logger
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 // опц. исходящие пинги
} }
// SetNotifier подключает исходящие пинги (до запуска Run).
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
// 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 +164,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 +183,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 +249,17 @@ 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)
}
}
} }
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает // Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
+27
View File
@@ -51,6 +51,23 @@ 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) 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 +100,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
+44 -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>