Compare commits
20 Commits
5087f35861
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
1e8000429a
|
|||
|
70b3c7ae14
|
|||
|
d727966f29
|
|||
|
d149cb7481
|
|||
|
b1f97c105a
|
|||
|
157f626c2e
|
|||
|
e297f0fb84
|
|||
|
16a82572e7
|
|||
|
093211c9c7
|
|||
|
fff0960915
|
|||
|
0e69a86a89
|
|||
|
81ed58ecff
|
|||
|
d4bf8a8cad
|
|||
|
5fb2f4df43
|
|||
|
2dbbb1b706
|
|||
|
4e077d878e
|
|||
|
7f7f5f69d4
|
|||
|
4af3ad2dde
|
|||
|
08b707f602
|
|||
|
7419bcb125
|
+10
-1
@@ -3,9 +3,18 @@
|
||||
# CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o jellybit ./cmd/jellybit
|
||||
# distroless/static несёт CA-сертификаты (HTTPS к LLM/TMDB). Пользователь
|
||||
# задаётся в compose (user: "1000:1000").
|
||||
#
|
||||
# Тома (см. compose): /config (ro, рендерится плейбуком — восстановимо при
|
||||
# деплое) + /data (SQLite, бекапить-и-не-терять).
|
||||
FROM gcr.io/distroless/static-debian12
|
||||
|
||||
COPY jellybit /usr/local/bin/jellybit
|
||||
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/data/config.toml"]
|
||||
|
||||
# В distroless нет shell/curl — проверку делает сам бинарь (порт берёт из
|
||||
# /config/config.toml — дефолтный путь). compose может переопределить параметры.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD ["/usr/local/bin/jellybit", "healthcheck"]
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/jellybit", "--config", "/config/config.toml"]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Jellybit
|
||||
|
||||
Jellybit — связующий сервис между qBittorrent и Jellyfin. Принимает
|
||||
торрент (magnet, `.torrent` или ссылку) вместе с текстовым контекстом,
|
||||
ставит загрузку в qBittorrent, дожидается её завершения, распознаёт
|
||||
содержимое (фильм или сериал, сезоны и серии) и раскладывает готовые
|
||||
файлы по конвенциям библиотеки Jellyfin.
|
||||
magnet-ссылку вместе с текстовым контекстом, ставит загрузку в
|
||||
qBittorrent, дожидается её завершения, распознаёт содержимое (фильм или
|
||||
сериал, сезоны и серии) и раскладывает готовые файлы по конвенциям
|
||||
библиотеки Jellyfin.
|
||||
|
||||
Полный замысел и причины — в [BRIEF.md](BRIEF.md).
|
||||
|
||||
@@ -18,25 +18,34 @@ Arr-стек (prowlarr/radarr/sonarr) плохо ложится на русск
|
||||
|
||||
## Как работает
|
||||
|
||||
1. Точка входа принимает torrent/magnet + контекст (HTTP API, веб-UI
|
||||
или Telegram-бот).
|
||||
1. Точка входа принимает magnet + контекст (HTTP API, веб-UI,
|
||||
Telegram-бот или CLI).
|
||||
2. Загрузка ставится в qBittorrent в выделенную категорию.
|
||||
3. Сервис отслеживает завершение загрузки.
|
||||
4. По именам файлов, контексту и (опц.) базам метаданных определяется
|
||||
фильм/сериал и нужная раскладка.
|
||||
5. Файлы **хардлинкаются** в библиотеку Jellyfin — источник остаётся в
|
||||
раздаче, место на диске не дублируется.
|
||||
6. После раскладки сервис (опц.) просит Jellyfin пересканировать
|
||||
медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
|
||||
|
||||
При высокой уверенности раскладка выполняется автоматически, иначе —
|
||||
уходит на подтверждение человеку.
|
||||
|
||||
Доступ к внешним сервисам (LLM, базы метаданных, Telegram) при
|
||||
необходимости идёт через HTTP-прокси — задаётся полем `proxy` в
|
||||
соответствующих секциях конфигурации.
|
||||
|
||||
## Статус
|
||||
|
||||
Ранняя разработка. Готовы каркас (Ф0) и приём + трекинг (Ф1): добавление
|
||||
magnet в qBittorrent, идемпотентность по infohash, поллинг завершения и
|
||||
машина состояний (`downloading → completed`, плюс stuck/failed); наружу —
|
||||
REST API, веб-UI и `jellybit add`. Источники кроме magnet (.torrent/url) и
|
||||
распознавание (Ф2) — дальше. См. [дорожную карту](docs/drafts/roadmap.md).
|
||||
Рабочий прототип с полным сквозным путём: приём magnet → загрузка в
|
||||
qBittorrent → распознавание (LLM + опционально базы метаданных
|
||||
TMDB/TVDB/TVMaze) → раскладка в библиотеку хардлинками, автоматически при
|
||||
уверенном результате либо через подтверждение человеком. Транспорты приёма:
|
||||
REST API, веб-UI, Telegram-бот и CLI (`jellybit add`).
|
||||
|
||||
Из источников пока поддержан magnet; `.torrent` и обычные ссылки — в планах.
|
||||
См. [дорожную карту](docs/drafts/roadmap.md).
|
||||
|
||||
## Документация
|
||||
|
||||
@@ -67,7 +76,26 @@ task build # статический бинарь (linu
|
||||
task image # docker-образ из готового бинаря
|
||||
```
|
||||
|
||||
Отладка распознавания на реальной раздаче (только чтение, без раскладки):
|
||||
|
||||
```bash
|
||||
jellybit recognize <infohash> --dry-run [--context "..."] --config ./config.toml
|
||||
```
|
||||
|
||||
Берёт торрент из qBittorrent по infohash, прогоняет распознавание (LLM +
|
||||
метабазы) и печатает план: тип/название/год, матч в базе, решение авто/review
|
||||
и превью целевых путей — то, что создалось бы при Apply.
|
||||
|
||||
## Доставка
|
||||
|
||||
Сборка здесь → готовый бинарь копируется на медиа-сервер umbar
|
||||
(`/home/av/projects/private/umbar`). Деплой-обвязка живёт в umbar.
|
||||
Рассчитан на домашний медиа-сервер. Артефакты репозитория — статический
|
||||
бинарь (`task build`) и `Dockerfile` (упаковка в `distroless/static`). Образ
|
||||
собирается **на сервере** из доставленного бинаря, поэтому Go-тулчейн на
|
||||
сервере не нужен. В distroless нет shell/curl, поэтому HEALTHCHECK зовёт сам
|
||||
бинарь: `jellybit healthcheck` (GET `/healthz` по порту из конфига, exit 0/1).
|
||||
|
||||
Контейнер: `user 1000:1000`, порт `8080` на хост, mount `/srv/media` (единая
|
||||
песочница для хардлинков) + том `/config` (ro, `config.toml`, восстановим при
|
||||
деплое) + data-том `/data` (SQLite, бекапить); к qBittorrent — по сети Docker.
|
||||
Конкретная деплой-обвязка (плейбук, секреты) держится в отдельном приватном
|
||||
репозитории и в комплект не входит.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/config"
|
||||
)
|
||||
|
||||
// runHealthcheck дёргает /healthz локального сервиса и завершается с кодом 0
|
||||
// при 200, иначе ненулевым. Нужен для HEALTHCHECK в distroless-образе, где
|
||||
// нет shell/curl: docker зовёт сам бинарь.
|
||||
func runHealthcheck(args []string) error {
|
||||
fs := flag.NewFlagSet("healthcheck", flag.ContinueOnError)
|
||||
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// listen вида ":8080" или "127.0.0.1:8080" → стучимся на localhost:<port>.
|
||||
_, port, err := net.SplitHostPort(cfg.HTTP.Listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse http.listen %q: %w", cfg.HTTP.Listen, err)
|
||||
}
|
||||
url := "http://127.0.0.1:" + port + "/healthz"
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("healthcheck request: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("healthcheck: status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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("ожидалась ошибка без сервера")
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
//
|
||||
// jellybit [serve] --config <path> запустить сервис (по умолчанию)
|
||||
// jellybit add <magnet> [--context] добавить загрузку через REST API сервиса
|
||||
// jellybit recognize <infohash> --dry-run показать план распознавания (без записи)
|
||||
// jellybit healthcheck --config <p> проверить /healthz (для docker HEALTHCHECK)
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -27,6 +29,10 @@ func main() {
|
||||
err = runServe(args)
|
||||
case "add":
|
||||
err = runAdd(args)
|
||||
case "recognize":
|
||||
err = runRecognize(args)
|
||||
case "healthcheck":
|
||||
err = runHealthcheck(args)
|
||||
default:
|
||||
_, _ = os.Stderr.WriteString("unknown command: " + cmd + "\n")
|
||||
os.Exit(2)
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/config"
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
// runRecognize — диагностика распознавания: берёт торрент из qBittorrent по
|
||||
// infohash, прогоняет конвейер (LLM + метабазы) и печатает план раскладки.
|
||||
// Только чтение: ни записи в БД, ни хардлинков.
|
||||
func runRecognize(args []string) error {
|
||||
fs := flag.NewFlagSet("recognize", flag.ContinueOnError)
|
||||
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
|
||||
dryRun := fs.Bool("dry-run", true, "только показать план, без изменений (единственный режим)")
|
||||
contextStr := fs.String("context", "", "доп. текстовый контекст для распознавания")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
infohash := strings.ToLower(strings.TrimSpace(fs.Arg(0)))
|
||||
// flag останавливается на первом позиционном — допарсим флаги, стоящие
|
||||
// после <infohash> (напр. `recognize <infohash> --dry-run`).
|
||||
if fs.NArg() > 1 {
|
||||
if err := fs.Parse(fs.Args()[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if infohash == "" {
|
||||
return fmt.Errorf("usage: jellybit recognize <infohash> [--dry-run] [--context ...]")
|
||||
}
|
||||
if !*dryRun {
|
||||
return fmt.Errorf("recognize runs only in --dry-run mode; layout is applied via review")
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.LLM.Type == "" || cfg.LLM.BaseURL == "" {
|
||||
return fmt.Errorf("[llm] is not configured in config — recognition is unavailable")
|
||||
}
|
||||
// Внутренние логи (ретраи/ошибки провайдеров) — в stderr, чтобы не мешать
|
||||
// плану в stdout.
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn}))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// qBittorrent: ищем торрент и его файлы.
|
||||
qb, err := qbt.New(qbt.Config{
|
||||
URL: cfg.QBittorrent.URL,
|
||||
Username: cfg.QBittorrent.Username,
|
||||
Password: cfg.QBittorrent.Password,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
torrents, err := qb.Torrents(ctx, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("qbittorrent: %w", err)
|
||||
}
|
||||
t, ok := findTorrent(torrents, infohash)
|
||||
if !ok {
|
||||
return fmt.Errorf("torrent with infohash %s not found in qBittorrent", infohash)
|
||||
}
|
||||
files, err := qb.Files(ctx, t.Hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("qbittorrent files: %w", err)
|
||||
}
|
||||
|
||||
// Провайдеры метаданных + LLM + распознаватель.
|
||||
providers, err := metadataProviders(cfg, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
provider, err := llm.New(llm.Config{
|
||||
Type: cfg.LLM.Type, BaseURL: cfg.LLM.BaseURL, APIKey: cfg.LLM.APIKey,
|
||||
Model: cfg.LLM.Model, Proxy: cfg.LLM.Proxy, Timeout: cfg.LLM.Timeout.Std(),
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rec := recognize.New(provider, providers, recognize.Config{
|
||||
MaxRetries: cfg.LLM.MaxRetries,
|
||||
AutoThreshold: cfg.Recognition.AutoConfidenceThreshold,
|
||||
}, logger)
|
||||
|
||||
in := recognize.Input{Name: t.Name, Context: *contextStr}
|
||||
for _, f := range files {
|
||||
in.Files = append(in.Files, recognize.File{Path: f.Name, Size: f.Size})
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
res, err := rec.Recognize(ctx, in)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recognize: %w", err)
|
||||
}
|
||||
|
||||
// Раскладчик для превью (BuildLinks ничего не пишет; логгер не нужен).
|
||||
lay, err := layout.New(layout.Config{MoviesDir: cfg.Paths.Movies, SeriesDir: cfg.Paths.Series}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
printDryRun(t, files, res, lay, providerNames(providers), time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
func findTorrent(torrents []qbt.Torrent, infohash string) (qbt.Torrent, bool) {
|
||||
for _, t := range torrents {
|
||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||
if h != "" && strings.EqualFold(h, infohash) {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return qbt.Torrent{}, false
|
||||
}
|
||||
|
||||
func printDryRun(t qbt.Torrent, files []qbt.File, res recognize.Result, lay *layout.Layouter, providers []string, took time.Duration) {
|
||||
p := res.Plan
|
||||
fmt.Printf("════ Торрент ════\n")
|
||||
fmt.Printf("name : %s\n", t.Name)
|
||||
fmt.Printf("infohash : %s\n", t.Hash)
|
||||
fmt.Printf("save_path : %s\n", t.SavePath)
|
||||
fmt.Printf("файлов : %d state: %s\n\n", len(files), t.State)
|
||||
|
||||
fmt.Printf("════ Распознавание ════\n")
|
||||
fmt.Printf("провайдеры базы: %v\n", providers)
|
||||
fmt.Printf("заняло : %s, попыток LLM: %d\n", took.Truncate(time.Millisecond), res.Attempts)
|
||||
fmt.Printf("тип : %s\n", p.Type)
|
||||
fmt.Printf("название : %s", p.Title)
|
||||
if p.OriginalTitle != "" {
|
||||
fmt.Printf(" (ориг: %s)", p.OriginalTitle)
|
||||
}
|
||||
fmt.Printf("\nгод : %d\n", p.Year)
|
||||
fmt.Printf("self-confidence: %.2f\n", p.Confidence)
|
||||
if p.Notes != "" {
|
||||
fmt.Printf("notes : %s\n", p.Notes)
|
||||
}
|
||||
|
||||
fmt.Printf("\n──── Матч в базе ────\n")
|
||||
if m := res.Match; m != nil {
|
||||
fmt.Printf("provider=%s id=%s title=%q year=%d\n", m.Provider, m.ProviderID, m.Title, m.Year)
|
||||
if len(m.SeasonEpisodeCounts) > 0 {
|
||||
fmt.Printf("серий по сезонам в базе: %v\n", m.SeasonEpisodeCounts)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("единичного сильного матча нет\n")
|
||||
}
|
||||
if len(res.Candidates) > 0 {
|
||||
fmt.Printf("кандидаты для ручного выбора (%d):\n", len(res.Candidates))
|
||||
for _, c := range res.Candidates {
|
||||
tagP, tagID := recognize.CandidateTag(c)
|
||||
fmt.Printf(" · %s/%s %q (%d) [тег: %s-%s]\n", c.Provider, c.ID, c.Title, c.Year, tagP, tagID)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n──── Решение ────\n")
|
||||
if res.Decision.Auto {
|
||||
fmt.Printf("АВТО-раскладка (review не нужен)\n")
|
||||
} else {
|
||||
fmt.Printf("REVIEW — причины:\n")
|
||||
for _, reason := range res.Decision.Reasons {
|
||||
fmt.Printf(" · %s\n", reason)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("\n──── Превью раскладки (хардлинки НЕ создаются) ────\n")
|
||||
tag := ""
|
||||
if res.Match != nil {
|
||||
tag = worker.ProviderTag(res.Match.Provider, res.Match.ProviderID)
|
||||
}
|
||||
links, err := lay.BuildLinks(worker.ToLayoutPlan(p, t.SavePath, tag))
|
||||
if err != nil {
|
||||
fmt.Printf("план не построился: %v\n", err)
|
||||
return
|
||||
}
|
||||
for _, l := range links {
|
||||
fmt.Printf(" [%s] %s\n", l.Kind, l.Dst)
|
||||
}
|
||||
fmt.Printf("\nИтого ссылок: %d (это создалось бы при Apply)\n", len(links))
|
||||
}
|
||||
|
||||
func providerNames(providers []metadata.Provider) []string {
|
||||
out := make([]string, len(providers))
|
||||
for i, p := range providers {
|
||||
out[i] = p.Name()
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRecognize_RequiresInfohash(t *testing.T) {
|
||||
if err := runRecognize(nil); err == nil || !strings.Contains(err.Error(), "usage") {
|
||||
t.Errorf("без infohash ожидалась usage-ошибка, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognize_DryRunOnly(t *testing.T) {
|
||||
// Флаг после позиционного должен разобраться (допарсинг), а --dry-run=false
|
||||
// — отклониться до обращения к конфигу/сети.
|
||||
err := runRecognize([]string{"abc123", "--dry-run=false"})
|
||||
if err == nil || !strings.Contains(err.Error(), "dry-run") {
|
||||
t.Errorf("ожидалась ошибка про dry-run, got %v", err)
|
||||
}
|
||||
}
|
||||
+94
-10
@@ -5,14 +5,19 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"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/httpapi"
|
||||
"git.vakhrushev.me/av/jellybit/internal/ingest"
|
||||
"git.vakhrushev.me/av/jellybit/internal/jellyfin"
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/llm"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logging"
|
||||
@@ -20,6 +25,7 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
"git.vakhrushev.me/av/jellybit/internal/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
"git.vakhrushev.me/av/jellybit/internal/tgbot"
|
||||
"git.vakhrushev.me/av/jellybit/internal/worker"
|
||||
)
|
||||
|
||||
@@ -27,7 +33,7 @@ import (
|
||||
// воркер (фоном) → HTTP-сервер; останавливается по SIGINT/SIGTERM.
|
||||
func runServe(args []string) error {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
configPath := fs.String("config", "/data/config.toml", "путь к config.toml")
|
||||
configPath := fs.String("config", "/config/config.toml", "путь к config.toml")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -51,7 +57,7 @@ func runServe(args []string) error {
|
||||
URL: cfg.QBittorrent.URL,
|
||||
Username: cfg.QBittorrent.Username,
|
||||
Password: cfg.QBittorrent.Password,
|
||||
})
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -62,7 +68,7 @@ func runServe(args []string) error {
|
||||
}, logger)
|
||||
|
||||
// Ф4: базы метаданных (опц.). Без них авто-раскладки нет — всё в review.
|
||||
providers, err := metadataProviders(cfg)
|
||||
providers, err := metadataProviders(cfg, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -81,7 +87,7 @@ func runServe(args []string) error {
|
||||
Model: cfg.LLM.Model,
|
||||
Proxy: cfg.LLM.Proxy,
|
||||
Timeout: cfg.LLM.Timeout.Std(),
|
||||
})
|
||||
}, logger)
|
||||
if perr != nil {
|
||||
return fmt.Errorf("llm provider: %w", perr)
|
||||
}
|
||||
@@ -97,19 +103,40 @@ func runServe(args []string) error {
|
||||
layouter, err := layout.New(layout.Config{
|
||||
MoviesDir: cfg.Paths.Movies,
|
||||
SeriesDir: cfg.Paths.Series,
|
||||
})
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("layouter: %w", err)
|
||||
}
|
||||
|
||||
wrk := worker.New(st, qb, recognizer, layouter, worker.Config{
|
||||
Category: cfg.QBittorrent.Category,
|
||||
Tag: cfg.QBittorrent.Tag,
|
||||
SavePath: cfg.QBittorrent.SavePath,
|
||||
PathMap: cfg.QBittorrent.PathMap,
|
||||
PollInterval: cfg.Worker.PollInterval.Std(),
|
||||
StuckAfter: cfg.Worker.StuckAfter.Std(),
|
||||
MagnetTimeout: cfg.Worker.MagnetTimeout.Std(),
|
||||
}, logger)
|
||||
|
||||
// Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin
|
||||
// не валит сервис — скан просто не сработает (залогируется в воркере).
|
||||
if cfg.Jellyfin.Enabled {
|
||||
if cfg.Jellyfin.URL == "" || cfg.Jellyfin.APIKey == "" {
|
||||
return fmt.Errorf("jellyfin enabled, but url or api_key is empty")
|
||||
}
|
||||
jf, jerr := jellyfin.New(jellyfin.Config{
|
||||
URL: cfg.Jellyfin.URL,
|
||||
APIKey: cfg.Jellyfin.APIKey,
|
||||
Proxy: cfg.Jellyfin.Proxy,
|
||||
Timeout: cfg.Jellyfin.Timeout.Std(),
|
||||
}, logger)
|
||||
if jerr != nil {
|
||||
return fmt.Errorf("jellyfin client: %w", jerr)
|
||||
}
|
||||
wrk.SetScanner(jf)
|
||||
logger.Info("jellyfin rescan enabled", "url", cfg.Jellyfin.URL)
|
||||
}
|
||||
|
||||
router, err := httpapi.NewRouter(httpapi.Deps{
|
||||
Logger: logger,
|
||||
Ingestor: ingestor,
|
||||
@@ -124,6 +151,32 @@ func runServe(args []string) error {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
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)
|
||||
|
||||
srv := &http.Server{
|
||||
@@ -156,27 +209,58 @@ func runServe(args []string) error {
|
||||
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 собирает включённые конфигом базы метаданных. Для
|
||||
// сериалов 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
|
||||
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{
|
||||
APIKey: cfg.Metadata.TVDB.APIKey,
|
||||
Proxy: cfg.Metadata.TVDB.Proxy,
|
||||
Timeout: cfg.Metadata.TVDB.Timeout.Std(),
|
||||
})
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tvdb provider: %w", err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if cfg.Metadata.TMDB.Enabled {
|
||||
if cfg.Metadata.TMDB.Enabled && cfg.Metadata.TMDB.APIKey != "" {
|
||||
p, err := metadata.NewTMDB(metadata.TMDBConfig{
|
||||
APIKey: cfg.Metadata.TMDB.APIKey,
|
||||
Proxy: cfg.Metadata.TMDB.Proxy,
|
||||
Timeout: cfg.Metadata.TMDB.Timeout.Std(),
|
||||
})
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tmdb provider: %w", err)
|
||||
}
|
||||
|
||||
+20
-6
@@ -1,14 +1,14 @@
|
||||
# Пример конфигурации jellybit. Реальный config.toml рендерится Ansible'ом
|
||||
# из переменных umbar и не коммитится (секреты — vars/secrets.yml).
|
||||
# Для локального запуска: db_path -> ./jellybit.db.
|
||||
# Пример конфигурации jellybit. Реальный config.toml не коммитится (содержит
|
||||
# секреты). Для локального запуска: db_path -> ./jellybit.db.
|
||||
|
||||
[qbittorrent]
|
||||
url = "http://qbit:8989" # по имени сервиса в общей docker-сети
|
||||
username = "admin"
|
||||
password = ""
|
||||
category = "jellybit"
|
||||
category = "jellybit" # категория для добавляемых jellybit раздач (push)
|
||||
tag = "jellybit" # тег для усыновления существующих раздач (pull, не двигает файлы)
|
||||
savepath = "/srv/media/downloads" # qBit кладёт загрузки сюда (задаём при добавлении)
|
||||
path_map = {} # фолбэк трансляции путей; обычно пуст
|
||||
path_map = {} # фолбэк: префикс save_path → хост-префикс, напр. {"/data" = "/srv/media"}; обычно пуст
|
||||
|
||||
[paths]
|
||||
downloads = "/srv/media/downloads"
|
||||
@@ -40,6 +40,18 @@ api_key = ""
|
||||
proxy = ""
|
||||
timeout = "10s"
|
||||
|
||||
[metadata.tvmaze]
|
||||
enabled = false # без ключа; только сериалы, тег [tvdbid-…] из externals
|
||||
proxy = ""
|
||||
timeout = "10s"
|
||||
|
||||
[jellyfin]
|
||||
enabled = false # включить пересканирование медиатеки после раскладки
|
||||
url = "http://jellyfin:8096" # по имени сервиса в общей docker-сети
|
||||
api_key = "" # API-ключ Jellyfin (Dashboard → API Keys)
|
||||
proxy = "" # опц. HTTP-прокси
|
||||
timeout = "10s"
|
||||
|
||||
[worker]
|
||||
poll_interval = "5s"
|
||||
stuck_after = "1h"
|
||||
@@ -52,10 +64,12 @@ auto_confidence_threshold = 0.85
|
||||
enabled = false
|
||||
token = ""
|
||||
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
||||
web_base_url = "" # напр. "http://jellybit:8080" — для кнопки «открыть в вебе»
|
||||
proxy = "" # опц. HTTP-прокси для api.telegram.org
|
||||
|
||||
[http]
|
||||
listen = ":8080"
|
||||
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
|
||||
trusted_subnets = [] # ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN); зарезервировано
|
||||
|
||||
[log]
|
||||
level = "info"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Авто-раскладка только при подтверждённом матче в метабазе
|
||||
|
||||
- Дата: 2026-06-13
|
||||
|
||||
## Контекст
|
||||
|
||||
jellybit распознаёт содержимое релиза через LLM по **недоверенным**
|
||||
сигналам: имя торрента, текстовый контекст человека, распарсенное
|
||||
сообщение бота — всё управляется извне и может содержать инъекции. По
|
||||
результату распознавания нужно решить: разложить файлы хардлинками
|
||||
автоматически или отправить на ревью человеку. Цена ошибки авто-раскладки
|
||||
реальна — мусор в библиотеке Jellyfin под неверным названием/папкой,
|
||||
возможно поверх чужого. Хочется максимум авто, но не ценой тихих ошибок.
|
||||
|
||||
Силы и ограничения:
|
||||
|
||||
- LLM хорошо разбирает русские и релиз-имена, но галлюцинирует, а его
|
||||
самооценка (`confidence`) плохо откалибрована и тривиально поддаётся
|
||||
инъекции из тех же недоверенных сигналов.
|
||||
- Внешние базы (TMDB/TVDB/TVMaze) дают **независимый** авторитетный сигнал:
|
||||
каноническое имя + `provider_id`. Но русские релизы и аниме часто в них
|
||||
отсутствуют.
|
||||
- Безопасность раскладки уже держится на валидации пути, не на промпте
|
||||
(см. [recognition.md](../specs/recognition.md)); решение «авто vs review» —
|
||||
второй слой защиты, на уровне доверия результату.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Гейт по самооценке LLM (`confidence ≥ порог`).** Просто и даёт
|
||||
максимум авто. Но `confidence` не откалибрована и инъектируема —
|
||||
«уверенный» неверный ответ прошёл бы молча. Небезопасно.
|
||||
- **LLM + структурная валидация, без обязательной базы.** Ловит часть
|
||||
ошибок (число файлов у фильма, дыры/дубли в нумерации S·E), но не ловит
|
||||
«правильную структуру под неверным названием». Недостаточно как
|
||||
единственный гейт авто.
|
||||
- **Авто только при подтверждённом матче в базе + валидация +
|
||||
согласованность сигналов.** Независимый авторитет снимает риск «LLM
|
||||
придумал». Цена — рус/аниме (нет в базах) всегда идут в review, но это и
|
||||
так нужный кейс.
|
||||
|
||||
## Решение
|
||||
|
||||
Авто-раскладку делаем, только если выполнено **всё**: (1) единственный
|
||||
сильный матч в метабазе по названию+году, давший `provider_id`;
|
||||
(2) структурная валидация без предупреждений; (3) пред-парс (`go-ptn`) и
|
||||
LLM не противоречат по типу/названию/году. Нет матча или база выключена →
|
||||
**всегда review**. Самооценку LLM учитываем лишь как вспомогательный
|
||||
сигнал, не как гейт.
|
||||
|
||||
Почему так: безопасность держится на **независимой** проверке (база), а не
|
||||
на доверии к выходу LLM, построенному из недоверенных данных. Это разом
|
||||
закрывает основной кейс (рус/аниме отсутствуют в базах → человек
|
||||
подтверждает) и убирает целый класс тихих ошибок «модель уверенно
|
||||
ошиблась». Review здесь — не наказание, а штатный режим для всего, что
|
||||
база не подтвердила (петля «догадка → подсказка → перераспознавание», см.
|
||||
[review-ux.md](../specs/review-ux.md)). Полная модель уверенности — в
|
||||
[recognition.md](../specs/recognition.md).
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Нет тихих авто-ошибок раскладки: всё неподтверждённое видит человек.
|
||||
- `+` `provider_id` из базы заодно даёт каноническое имя папки
|
||||
(`[tmdbid-…]`) — Jellyfin не путает русские названия.
|
||||
- `−` Рус/аниме и всё, чего нет в базах, всегда требует ручного
|
||||
подтверждения — авто там недоступно by design.
|
||||
- `−` Без включённых TMDB/TVDB/TVMaze авто-раскладки нет вовсе: сервис
|
||||
работает в режиме «распознал → review».
|
||||
- Делает цикл ревью критичным: если он неудобен, ручное подтверждение
|
||||
станет узким местом — поэтому review-ux вынесен в отдельную спеку.
|
||||
@@ -56,6 +56,7 @@
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| ---------- | ---------------------------------------------------------------- | ------ |
|
||||
| 2026-06-13 | [Авто-раскладка только при матче в метабазе](ADR-2026-06-13-auto-link-requires-db-match.md) | — |
|
||||
| 2026-06-13 | [Docker как единица деплоя](ADR-2026-06-13-docker-deploy.md) | — |
|
||||
| 2026-06-13 | [Хардлинки вместо копирования и симлинков](ADR-2026-06-13-hardlinks.md) | — |
|
||||
| 2026-06-13 | [Go и доставка одним бинарём](ADR-2026-06-13-go-single-binary.md) | — |
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
## Записи
|
||||
|
||||
- [architecture.md](architecture.md) — общее устройство: компоненты,
|
||||
поток, машина состояний, хранилище, конфигурация.
|
||||
транспорты, хранилище, раскладка, деплой.
|
||||
- [workflow.md](workflow.md) — жизненный цикл загрузки: машина состояний,
|
||||
переходы, сопоставление состояний qBittorrent.
|
||||
- [recognition.md](recognition.md) — распознавание контента и модель
|
||||
уверенности.
|
||||
- [review-ux.md](review-ux.md) — ревью раскладки человеком: UI/UX-сценарии
|
||||
|
||||
+63
-132
@@ -36,47 +36,21 @@ qBittorrent, определяет содержимое (фильм или сер
|
||||
| `worker` | владелец машины состояний; поллинг, сериализация команд |
|
||||
| `recognize` | пред-парс имени + вызов LLM + модель уверенности |
|
||||
| `llm` | провайдер LLM за интерфейсом (дискриминатор `type`) |
|
||||
| `metadata` | интерфейс баз метаданных + TMDB/TVDB (опц.) |
|
||||
| `metadata` | интерфейс баз метаданных + TMDB/TVDB/TVMaze (опц.) |
|
||||
| `layout` | конвенции Jellyfin, санитизация путей, хардлинкер, undo |
|
||||
| `store` | SQLite: загрузки, распознавание, подсказки, ссылки |
|
||||
| `httpapi` | REST + веб-UI (server-rendered, htmx) |
|
||||
| `httpapi` | REST + веб-UI (server-rendered, POST-формы с redirect) |
|
||||
| `tgbot` | Telegram: приём + парсер сообщений бота + исходящие пинги |
|
||||
| `jellyfin` | триггер пересканирования медиатеки после раскладки (опц.) |
|
||||
| `config` | загрузка TOML-конфига |
|
||||
|
||||
## Поток и машина состояний
|
||||
|
||||
```
|
||||
ingest → downloading → completed → recognizing ──┬─ авто ────────────────→ linking → done
|
||||
│ │ │ └─ review ⇄ recognizing ─→ linking → done
|
||||
│ │ └─ moving/checking (ещё не готов)
|
||||
│ └─ stuck (не качается дольше таймаута)
|
||||
└─ failed ⇄ retry
|
||||
|
||||
done → undo → reverted
|
||||
review → «Позже» → deferred → review
|
||||
любой → «Отклонить» → cancelled
|
||||
```
|
||||
|
||||
- **ingest** — приняли источник + контекст, отдали в qBittorrent
|
||||
(категория `jellybit`), записали в БД с ключом идемпотентности.
|
||||
- **downloading / completed** — `worker` поллит qBittorrent по категории
|
||||
(`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте
|
||||
(не `moving`/`checking*`), см. «Завершение в qBittorrent».
|
||||
- **recognizing** — `recognize` строит план и оценку уверенности
|
||||
([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM →
|
||||
review (не failed).
|
||||
- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл
|
||||
`review ⇄ recognizing` — перераспознавание по подсказке.
|
||||
- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем.
|
||||
- **done** — опционально дёргаем скан Jellyfin; доступен **undo** →
|
||||
`reverted` (убрать созданные ссылки).
|
||||
- **deferred / cancelled / failed / stuck** — «Позже», «Отклонить»,
|
||||
ошибка (ретраибельна), не качается дольше таймаута.
|
||||
|
||||
Все переходы и команды идут через `worker` под per-download блокировкой —
|
||||
два транспорта не гонятся за одно состояние. Состояние персистентно в
|
||||
SQLite; на старте `worker` сверяет категорию qBittorrent с БД и
|
||||
продолжает.
|
||||
Жизненный цикл загрузки (ingest → downloading → … → done/reverted),
|
||||
полный граф состояний с переходами и сопоставление состояний qBittorrent —
|
||||
в отдельной спецификации [workflow.md](workflow.md). Ключевое: переходами
|
||||
владеет `worker`, он же сериализует команды транспортов под per-download
|
||||
блокировкой, а состояние персистентно в SQLite.
|
||||
|
||||
## Транспорты
|
||||
|
||||
@@ -84,9 +58,10 @@ SQLite; на старте `worker` сверяет категорию qBittorrent
|
||||
reject / defer / undo) — команды к `worker`:
|
||||
|
||||
- **HTTP API + веб-UI** — форма «добавить», список, экран ревью
|
||||
(server-rendered + htmx). В v1 **без авторизации** (доверенная LAN), с
|
||||
опциональным allowlist подсетей (`http.trusted_subnets`). Защиту
|
||||
навесим позже — [drafts/ideas.md](../drafts/ideas.md).
|
||||
(server-rendered). В v1 **без авторизации** (доверенная LAN). Поле
|
||||
`http.trusted_subnets` зарезервировано, но **пока не применяется**:
|
||||
деплой только в локальную сеть без доступа из интернета, поэтому
|
||||
allowlist-middleware и авторизацию отложили — [drafts/ideas.md](../drafts/ideas.md).
|
||||
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится
|
||||
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
|
||||
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
|
||||
@@ -104,8 +79,10 @@ SQLite. Схема покрывает приём, цикл ревью и отк
|
||||
`idempotency_key`, состояние, `error_code`/`error_msg`, тайминги.
|
||||
(infohash может появиться позже приёма — для magnet без метаданных.)
|
||||
- `recognition` — попытки распознавания: `download_id`, `attempt_no`,
|
||||
`is_current`, тип, название, год, `provider` (`tmdb|tvdb|none`),
|
||||
`provider_id`, `confidence`, причины-не-авто, сырой ответ LLM.
|
||||
`is_current`, тип, название, год, `provider` (`tmdb|tvdb|tvmaze|none`),
|
||||
`provider_id`, `confidence`, причины-не-авто, сырой ответ LLM и
|
||||
структурированный `plan` (каноничный JSON `recognize.Plan` — файл →
|
||||
роль/сезон/серия для превью и применения).
|
||||
- `hint` — накопленные подсказки человека (`download_id`, текст, время).
|
||||
- `override` — запиненные ручные правки полей (перераспознавание не
|
||||
затирает).
|
||||
@@ -128,73 +105,19 @@ qBittorrent. Идемпотентность — **только для актив
|
||||
|
||||
## Конфигурация
|
||||
|
||||
TOML. В репозитории — `config.example.toml` с placeholder'ами; реальный
|
||||
`config.toml` рендерится при деплое Ansible-шаблоном из переменных umbar
|
||||
(секреты — `vars/secrets.yml` под ansible-vault), на диске **0600**,
|
||||
владелец `1000:1000`, не коммитится. Пример:
|
||||
TOML. Полный список параметров с комментариями — в
|
||||
[`config.example.toml`](../../config.example.toml) (источник истины, не
|
||||
дублируем его здесь). Реальный `config.toml` рендерится при деплое
|
||||
Ansible-шаблоном из переменных umbar (секреты — `vars/secrets.yml` под
|
||||
ansible-vault), на диске **0600**, владелец `1000:1000`, не коммитится.
|
||||
|
||||
```toml
|
||||
[qbittorrent]
|
||||
url = "http://qbit:8989" # по имени сервиса в общей docker-сети
|
||||
username = "admin"
|
||||
password = ""
|
||||
category = "jellybit"
|
||||
savepath = "/srv/media/downloads" # куда qBit кладёт загрузки (задаём при добавлении)
|
||||
# Обычно пусто: все медиа-приложения монтируют /srv/media:/srv/media идентично,
|
||||
# поэтому путь из API уже = хост-путь. path_map — фолбэк, если пути разойдутся.
|
||||
path_map = {}
|
||||
|
||||
[paths]
|
||||
# хост-пути (видны внутри контейнера через mount /srv/media)
|
||||
downloads = "/srv/media/downloads"
|
||||
movies = "/srv/media/movies"
|
||||
series = "/srv/media/series"
|
||||
|
||||
[llm]
|
||||
# type — дискриминатор реализации; пока поддерживается "openai-compat"
|
||||
type = "openai-compat"
|
||||
# LLM на хосте (LM Studio) из bridged-контейнера — через host.docker.internal,
|
||||
# не 127.0.0.1; либо вынести LLM в контейнер общей сети.
|
||||
base_url = "http://host.docker.internal:1234/v1"
|
||||
api_key = ""
|
||||
model = "qwen2.5-32b-instruct"
|
||||
proxy = "" # опц. HTTP-прокси (для удалённых эндпоинтов)
|
||||
timeout = "120s"
|
||||
max_retries = 3 # непарсящийся ответ после ретраев → review
|
||||
|
||||
[metadata.tmdb]
|
||||
enabled = false # включается ключом; без матча авто не делаем
|
||||
api_key = ""
|
||||
proxy = "" # опц. HTTP-прокси для доступа к базе
|
||||
timeout = "10s"
|
||||
|
||||
[metadata.tvdb]
|
||||
enabled = false
|
||||
api_key = ""
|
||||
proxy = ""
|
||||
timeout = "10s"
|
||||
|
||||
[worker]
|
||||
poll_interval = "5s" # как часто опрашивать qBittorrent
|
||||
stuck_after = "1h" # не качается дольше → stuck
|
||||
magnet_timeout = "30m" # magnet без метаданных дольше → failed
|
||||
|
||||
[recognition]
|
||||
auto_confidence_threshold = 0.85
|
||||
|
||||
[telegram]
|
||||
enabled = false
|
||||
token = ""
|
||||
allowed_user_ids = [] # пусто = запрет всем (fail-closed)
|
||||
|
||||
[http]
|
||||
listen = ":8080"
|
||||
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
|
||||
|
||||
[log]
|
||||
level = "info"
|
||||
format = "json"
|
||||
```
|
||||
Структура секций: `[qbittorrent]` (доступ + категория/тег для push/pull),
|
||||
`[paths]` (хост-пути песочницы), `[storage]` (путь к SQLite), `[llm]`
|
||||
(провайдер распознавания, см. [recognition.md](recognition.md)),
|
||||
`[metadata.tmdb|tvdb|tvmaze]` (опц. базы), `[jellyfin]` (опц.
|
||||
пересканирование), `[worker]` (интервал поллинга и таймауты, см.
|
||||
[workflow.md](workflow.md)), `[recognition]` (порог уверенности),
|
||||
`[telegram]`, `[http]`, `[log]`.
|
||||
|
||||
## Логирование
|
||||
|
||||
@@ -202,25 +125,6 @@ format = "json"
|
||||
Каждая загрузка проходит со сквозным идентификатором; решения
|
||||
распознавания (почему авто/ревью) и операции с файлами логируются явно.
|
||||
|
||||
## Завершение в qBittorrent
|
||||
|
||||
`worker` опрашивает qBittorrent по категории и сопоставляет состояния:
|
||||
|
||||
- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`queuedUP`/
|
||||
`forcedUP` — и **только** когда нет `moving`/`checkingUP`.
|
||||
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`queuedDL`/
|
||||
`checkingDL`/`forcedDL`.
|
||||
- **застряло:** `metaDL` дольше `magnet_timeout`, `stalledDL` дольше
|
||||
`stuck_after` → `stuck`/`failed`.
|
||||
- **ошибка:** `error`/`missingFiles` → `failed`.
|
||||
|
||||
Пути файлов берём из API (`save_path`/`content_path` + относительные
|
||||
имена), не из константы (обычно это уже хост-путь). «Incomplete»-каталог в
|
||||
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
|
||||
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
|
||||
`moving` — дожидаемся окончания переноса и только потом берём финальный
|
||||
путь).
|
||||
|
||||
## Раскладка файлов
|
||||
|
||||
`layout` создаёт хардлинки в `paths.movies`/`paths.series` по конвенциям
|
||||
@@ -238,8 +142,13 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
|
||||
доводит начатое (идемпотентно) либо откатывается.
|
||||
- **Undo** удаляет только ссылки своего `apply_batch_id` и только если
|
||||
путь под `paths.movies`/`series` — источник недосягаем.
|
||||
- **Одна ФС обязательна.** `link(2)` через границы ФС даёт `EXDEV` —
|
||||
падаем с понятной ошибкой; по построению этого не должно случаться.
|
||||
- **Хардлинк предпочтителен, но есть фолбэк.** По построению источник и
|
||||
цель — на одной ФС (единая песочница `/srv/media`), и `link(2)` проходит.
|
||||
Если ФС всё же не поддерживает жёсткие ссылки или они между разными ФС
|
||||
(`EXDEV`/`ENOTSUP`/`EOPNOTSUPP`/`EPERM`), `layout` **не падает**, а
|
||||
копирует файл (через временный файл + атомарный `rename`) и пишет в лог
|
||||
`Warn` (статус ссылки — `copied`): задача доходит до конца ценой
|
||||
дублирования места. Источник при этом всё равно не трогаем.
|
||||
|
||||
### Пути и контейнеры — единая песочница `/srv/media`
|
||||
|
||||
@@ -262,10 +171,27 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
|
||||
|
||||
- **qBit** — `savepath=/srv/media/downloads`, temp `/srv/media/incomplete`.
|
||||
- **jellybit** — читает `downloads`, пишет в `movies`/`series`; свой
|
||||
SQLite/конфиг — отдельным mount'ом `/srv/applications/jellybit/data`.
|
||||
SQLite — отдельным mount'ом `/srv/applications/jellybit/data`, конфиг —
|
||||
отдельным `/srv/applications/jellybit/config`.
|
||||
- **Jellyfin** — библиотеки указывают на `movies`/`series` (не на корень
|
||||
`/srv/media`, иначе в индекс попадут downloads/incomplete).
|
||||
|
||||
## Пересканирование Jellyfin
|
||||
|
||||
После успешной раскладки (вход в `done`) `worker` неблокирующе просит Jellyfin
|
||||
пересканировать медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
|
||||
Включается конфигом `[jellyfin]` (по умолчанию выключено); без него скан не
|
||||
дёргается.
|
||||
|
||||
- **Один вызов — `POST /Library/Refresh`** (скан всех библиотек). Скан
|
||||
инкрементальный, поэтому полный дёшев; точечный скан конкретной папки не
|
||||
делаем — сложнее и не в духе сервиса («минимум компонентов»).
|
||||
- **Авторизация** — API-ключ Jellyfin в заголовке `X-Emby-Token`.
|
||||
- **Неблокирующе и вне `w.mu`** (как пинги Telegram): вызов уходит в сеть в
|
||||
отдельной горутине с фоновым контекстом. Недоступность Jellyfin не влияет на
|
||||
состояние задачи — ошибка лишь логируется (`Warn`).
|
||||
- **Адресация** — по имени сервиса в общей docker-сети (`http://jellyfin:8096`).
|
||||
|
||||
## Деплой
|
||||
|
||||
Jellybit работает в **docker** — в одной среде с qBittorrent и Jellyfin
|
||||
@@ -286,10 +212,13 @@ Jellybit работает в **docker** — в одной среде с qBittorr
|
||||
- **`user: "1000:1000"`**, UMASK 022 — единый системный пользователь
|
||||
umbar; созданные каталоги 0755, файлы-ссылки наследуют inode источника.
|
||||
- **mount `/srv/media`** (единая песочница) — для хардлинков и move
|
||||
(см. «Пути и контейнеры»); `/srv/applications/jellybit/data` — отдельно.
|
||||
(см. «Пути и контейнеры»); каталоги jellybit — отдельно.
|
||||
- **mount конфига** `/srv/applications/jellybit/config` → `/config` (ro):
|
||||
`config.toml` (0600). Восстановим при деплое (рендерит плейбук umbar) —
|
||||
бекапить не нужно.
|
||||
- **mount данных** `/srv/applications/jellybit/data` → `/data`: SQLite
|
||||
(`/data/jellybit.db`) и `config.toml`. Без него редеплой стёр бы всё
|
||||
in-flight состояние.
|
||||
(`/data/jellybit.db`). Бекапить-и-не-терять — без него редеплой стёр бы
|
||||
всё in-flight состояние.
|
||||
- **healthcheck** на `/healthz`.
|
||||
|
||||
Разделение ответственности:
|
||||
@@ -328,6 +257,9 @@ Dockerfile .dockerignore config.example.toml
|
||||
задач (повторная закачка спустя время → новая задача).
|
||||
- Состояние — на persistent-томе `/srv/applications/jellybit/data`.
|
||||
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
|
||||
- Пересканирование Jellyfin после раскладки — `POST /Library/Refresh` (скан
|
||||
всех библиотек, инкрементальный), неблокирующе на входе в `done`; опц.,
|
||||
включается `[jellyfin]`.
|
||||
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
|
||||
- Авто-раскладка требует подтверждённого матча в базе; иначе review.
|
||||
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
|
||||
@@ -336,5 +268,4 @@ Dockerfile .dockerignore config.example.toml
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Конфигурация Jellyfin (URL + API-ключ) и триггер скана — когда Jellyfin
|
||||
будет развёрнут в umbar (сейчас его там нет).
|
||||
- (пока нет)
|
||||
|
||||
@@ -37,8 +37,9 @@ series/
|
||||
|
||||
## Сопоставление источник → цель
|
||||
|
||||
Источник берём по пути из qBittorrent (`save_path`/`content_path` +
|
||||
относительное имя; это уже хост-путь, `path_map` — фолбэк). Для каждого
|
||||
Источник берём по пути из qBittorrent (`save_path` + относительное имя
|
||||
файла из `/torrents/files`, которое уже содержит корневую папку
|
||||
многофайловой раздачи; это уже хост-путь, `path_map` — фолбэк). Для каждого
|
||||
распознанного **файла** (не каталога) создаётся **хардлинк** в
|
||||
`paths.movies`/`paths.series`; целевые каталоги — `mkdir` (0755,
|
||||
`1000:1000`). Исходный файл остаётся на месте (раздача продолжается),
|
||||
@@ -50,8 +51,11 @@ inode общий — диск не дублируется.
|
||||
же inode → готово; другой файл → коллизия → review). Инварианты и undo —
|
||||
в [architecture.md](architecture.md) → «Раскладка файлов».
|
||||
|
||||
Требование: целевой и исходный каталоги — на одной ФС/одном mount'е
|
||||
(внутри контейнера это обеспечивает единая песочница `/srv/media`).
|
||||
Желательно: целевой и исходный каталоги — на одной ФС/одном mount'е
|
||||
(внутри контейнера это обеспечивает единая песочница `/srv/media`), тогда
|
||||
работает дешёвый хардлинк. Если хардлинк невозможен (разные ФС или ФС без
|
||||
поддержки жёстких ссылок), `layout` не падает, а копирует файл с
|
||||
предупреждением в лог — см. architecture.md → «Раскладка файлов».
|
||||
|
||||
## Крайние случаи
|
||||
|
||||
|
||||
+24
-16
@@ -5,15 +5,18 @@
|
||||
По доступным сигналам определить: фильм или сериал; каноническое название
|
||||
и год; для сериала — сезон(ы) и соответствие файлов сериям; при включённых
|
||||
базах — провайдер и его id. На выходе — план раскладки, оценка уверенности
|
||||
и решение «авто или review».
|
||||
и решение «авто или review» (как оно встраивается в машину состояний —
|
||||
[workflow.md](workflow.md), состояния `recognizing`/`linking`/`review`).
|
||||
|
||||
## Сигналы
|
||||
|
||||
- Имя торрента и структура каталогов.
|
||||
- Список файлов с размерами и расширениями. Абсолютный путь источника
|
||||
восстанавливаем как `save_path`/`content_path` из qBit (= хост-путь;
|
||||
`path_map` обычно тождественен) + относительное имя файла; учитываем
|
||||
одно- и многофайловые торренты.
|
||||
восстанавливаем как `save_path` из qBit (= хост-путь; `path_map` обычно
|
||||
тождественен) + относительное имя файла из `/torrents/files`. Имя уже
|
||||
включает корневую папку для многофайловых торрентов, поэтому префикс —
|
||||
именно `save_path`, а не `content_path` (последний удвоил бы корневую
|
||||
папку и сломал бы однофайловые раздачи).
|
||||
- Текстовый контекст человека (+ накопленные подсказки из review).
|
||||
- Распарсенное сообщение торрент-бота (если через Telegram): название с
|
||||
годом, качество, переводы — см. пример в [BRIEF.md](../../BRIEF.md).
|
||||
@@ -31,8 +34,10 @@
|
||||
пред-парс, возвращает структурированный план в нашей схеме. Хорошо
|
||||
берёт русские релиз-имена. Длинный список файлов усекаем/семплируем под
|
||||
контекст модели.
|
||||
3. **Сверка с базой** (если включена TMDB/TVDB): ищем по названию+году,
|
||||
берём официальный id и каноническое имя, собираем кандидатов.
|
||||
3. **Сверка с базой** (если включена TMDB/TVDB/TVMaze): ищем по
|
||||
названию+году, берём официальный id и каноническое имя, собираем
|
||||
кандидатов. TVMaze — без ключа, только сериалы; внешний id
|
||||
(TVDB/IMDb) из `externals` идёт в имя папки.
|
||||
4. **Оценка уверенности** и решение: авто или review.
|
||||
|
||||
## Структура ответа LLM (предварительная)
|
||||
@@ -52,8 +57,8 @@ notes пояснения, неоднозначности
|
||||
Сезон/серия — **на файле**: так выражаются мультисезонные паки,
|
||||
спецвыпуски и смешанные раскладки; отдельного скалярного `season` нет.
|
||||
`provider_hint` — только подсказка для поиска; итоговые `provider`
|
||||
(`tmdb|tvdb|none`) и `provider_id` появляются после сверки с базой и
|
||||
хранятся отдельно.
|
||||
(`tmdb|tvdb|tvmaze|none`) и `provider_id` появляются после сверки с базой
|
||||
и хранятся отдельно.
|
||||
|
||||
## Провайдер LLM
|
||||
|
||||
@@ -65,22 +70,25 @@ notes пояснения, неоднозначности
|
||||
Chat Completions API (`base_url` + `api_key` + `model`). Подходят
|
||||
локальные серверы (LM Studio, llama.cpp, Ollama) и облачные совместимые
|
||||
провайдеры (DeepSeek, Qwen и др.).
|
||||
- **Структурированный вывод надёжно:** просим JSON по схеме
|
||||
(`response_format` со схемой где поддерживается; иначе json-режим или
|
||||
tool-call); на приёме срезаем ```-ограждения и извлекаем JSON,
|
||||
**валидируем в Go**, ретраим со схемой-в-промпте до `llm.max_retries`;
|
||||
если так и не распарсилось — уходим в **review** (не в `failed`) с
|
||||
причиной «ответ LLM не разобран». Серверы заметно различаются по
|
||||
поддержке строгих схем, особенно мелкие локальные модели.
|
||||
- **Структурированный вывод надёжно:** просим JSON-режим
|
||||
(`response_format: {"type":"json_object"}`) — это поддерживают и мелкие
|
||||
локальные модели, в отличие от строгих JSON Schema. На приёме срезаем
|
||||
```-ограждения и извлекаем JSON, **валидируем в Go** против нашей схемы;
|
||||
при ошибке разбора ретраим, передавая модели саму ошибку и схему в
|
||||
промпте, до `llm.max_retries`. Если так и не распарсилось — уходим в
|
||||
**review** (не в `failed`) с причиной «ответ LLM не разобран».
|
||||
- Новые типы (напр. нативный `anthropic`) добавляются, не трогая
|
||||
`recognize`.
|
||||
|
||||
## Модель уверенности
|
||||
|
||||
Почему авто только при матче в базе, а не по самооценке LLM —
|
||||
[ADR-2026-06-13-auto-link-requires-db-match](../adr/ADR-2026-06-13-auto-link-requires-db-match.md).
|
||||
|
||||
Авто-раскладка — только если выполнено **всё**:
|
||||
|
||||
1. **Подтверждённый матч в базе** — единственный сильный результат
|
||||
TMDB/TVDB по названию+году, давший `provider_id`. **Нет матча (или
|
||||
TMDB/TVDB/TVMaze по названию+году, давший `provider_id`. **Нет матча (или
|
||||
база выключена) → всегда review.** Это и закрывает основной кейс
|
||||
(рус/аниме часто отсутствуют в базах), и снимает риск «LLM придумал».
|
||||
2. **Структурная валидация** без предупреждений:
|
||||
|
||||
+28
-10
@@ -2,7 +2,8 @@
|
||||
|
||||
Что происходит, когда система не уверена в распознавании и не
|
||||
раскладывает файлы автоматически. Когда именно наступает ревью — см.
|
||||
[recognition.md](recognition.md); конвенции целевых имён —
|
||||
[recognition.md](recognition.md); место состояния `review` в общем потоке —
|
||||
[workflow.md](workflow.md); конвенции целевых имён —
|
||||
[jellyfin-layout.md](jellyfin-layout.md).
|
||||
|
||||
Главный принцип: ревью — это **петля «догадка → подсказка человека →
|
||||
@@ -82,9 +83,13 @@ Fargo.S02.2015.WEB-DL.1080p.rus.eng 🟡 review
|
||||
|
||||
- **🔁 Уточнить** → бот просит подсказку ответом → перераспознаёт →
|
||||
редактирует то же сообщение новым планом. Петля коррекции прямо в чате.
|
||||
- **🔢 Выбрать в базе** → кнопки по кандидатам (название · год · id).
|
||||
- Точечное переназначение файлов в чат не помещается → **🌐 Открыть в
|
||||
вебе** (deep-link на ту же страницу).
|
||||
- Точечное переназначение файлов и выбор кандидата базы в чат не
|
||||
помещаются → **🌐 В вебе** (deep-link на ту же страницу, строится из
|
||||
`telegram.web_base_url`).
|
||||
|
||||
> Реально в боте сейчас: ✅ Применить, 📺↔🎬 Тип, 🔁 Уточнить, 🕗 Позже,
|
||||
> 🌐 В вебе, ❌ Отклонить. Кнопки «🔢 Выбрать в базе» в чате пока нет —
|
||||
> выбор кандидата и ручной ввод id делаются в вебе.
|
||||
|
||||
## Разделение труда
|
||||
|
||||
@@ -122,12 +127,25 @@ Telegram = одобрить / подсказать / выбрать кандид
|
||||
- **«Позже»** паркует загрузку в `deferred` (вернётся в review по
|
||||
действию), **«Отклонить»** → `cancelled` (раскладку не делаем), **undo**
|
||||
после применения → `reverted` (удаляет только ссылки своего батча, под
|
||||
`media`). Полная карта состояний — в [architecture.md](architecture.md).
|
||||
`media`). Полная карта состояний — в [workflow.md](workflow.md).
|
||||
- После отката или отклонения доступна **«Привязать заново»**: перезапускает
|
||||
распознавание для той же раздачи (`reverted`/`cancelled → recognizing`) и
|
||||
снова приводит в review — раскладка всегда требует ручного подтверждения,
|
||||
авто не делаем. Нужна, когда распознали неверно: откатил/отклонил,
|
||||
перепривязал, поправил и применил.
|
||||
- В самом ревью, помимо **«Уточнить»** (подсказка + перераспознавание), есть
|
||||
**«Распознать заново»** — повторный прогон распознавания без новой подсказки
|
||||
(контекст и прежние подсказки уже учтены). Полезно, когда модель один раз
|
||||
споткнулась на разовой ошибке.
|
||||
|
||||
## Объём по версиям
|
||||
|
||||
- **Ф3 (первая версия):** подсказка + перераспознавание; из ручного —
|
||||
переключатель типа, выбор кандидата базы, пометка файла «игнор». Undo —
|
||||
есть.
|
||||
- **Ф5:** полный редактор маппинга «файл → серия», ручной режим,
|
||||
подтверждение в Telegram с reply-подсказкой и эскалацией в веб.
|
||||
- **Ф3 (готово):** в вебе — подсказка + перераспознавание, «Распознать
|
||||
заново», переключатель типа, выбор кандидата базы / ручной ввод id /
|
||||
«без базы», пометка файла «игнор», «Применить»/«Отклонить»/«Позже»,
|
||||
Undo и «Привязать заново». В Telegram — подтверждение с reply-подсказкой
|
||||
(«Уточнить»), переключатель типа, «Позже»/«Отклонить» и эскалация в веб;
|
||||
пинги о входе в review и готовности.
|
||||
- **Ф5 (на будущее):** полный редактор маппинга «файл → серия»
|
||||
(правка S·E, «нумеровать подряд»), ручной режим при полном провале LLM,
|
||||
выбор кандидата базы и ввод id прямо в Telegram.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Жизненный цикл загрузки и машина состояний
|
||||
|
||||
Как загрузка проходит путь от приёма источника до разложенных файлов:
|
||||
состояния, переходы и то, что их вызывает. Кто владеет переходами и общее
|
||||
устройство — в [architecture.md](architecture.md); детали распознавания —
|
||||
в [recognition.md](recognition.md); действия человека в ревью — в
|
||||
[review-ux.md](review-ux.md).
|
||||
|
||||
## Граф состояний
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> downloading: ingest (источник отдан в qBittorrent)
|
||||
|
||||
downloading --> completed: файлы на месте
|
||||
downloading --> stuck: stalledDL дольше stuck_after
|
||||
downloading --> failed: metaDL дольше magnet_timeout / error
|
||||
|
||||
completed --> recognizing
|
||||
|
||||
recognizing --> linking: авто (матч в базе + валидация)
|
||||
recognizing --> review: нужно подтверждение / ответ LLM не разобран
|
||||
|
||||
review --> linking: Применить
|
||||
review --> recognizing: Уточнить / Распознать заново
|
||||
review --> deferred: Позже
|
||||
review --> cancelled: Отклонить
|
||||
deferred --> review: любое действие (та же поверхность)
|
||||
|
||||
linking --> done
|
||||
linking --> review: коллизия цели
|
||||
linking --> failed: ошибка ФС
|
||||
|
||||
done --> reverted: Undo
|
||||
|
||||
reverted --> recognizing: Привязать заново
|
||||
cancelled --> recognizing: Привязать заново
|
||||
|
||||
stuck --> downloading: Retry
|
||||
failed --> downloading: Retry
|
||||
|
||||
done --> [*]
|
||||
cancelled --> [*]
|
||||
reverted --> [*]
|
||||
|
||||
note right of cancelled
|
||||
«Отклонить» доступно из любого
|
||||
нетерминального состояния
|
||||
end note
|
||||
```
|
||||
|
||||
Условно-терминальные состояния — `done`, `cancelled`, `failed`,
|
||||
`reverted`: задача в них останавливается, но из `failed`/`stuck` есть
|
||||
**Retry**, а из `reverted`/`cancelled` — **Привязать заново**. `stuck`
|
||||
восстановимо ретраем.
|
||||
|
||||
## Состояния и переходы
|
||||
|
||||
- **ingest → downloading** — приняли источник + контекст, отдали в
|
||||
qBittorrent (категория `qbittorrent.category`), записали в БД с ключом
|
||||
идемпотентности. См. [architecture.md](architecture.md) → «Транспорты».
|
||||
- **downloading / completed** — `worker` поллит qBittorrent
|
||||
(`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте
|
||||
(не `moving`/`checking*`), см. «Завершение в qBittorrent» ниже.
|
||||
- **recognizing** — `recognize` строит план и оценку уверенности
|
||||
([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM →
|
||||
review (не failed).
|
||||
- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл
|
||||
`review ⇄ recognizing` — перераспознавание по подсказке. «Уточнить» —
|
||||
подсказка + перераспознавание; «Распознать заново» — повторный прогон
|
||||
без новой подсказки, по уже накопленному контексту и подсказкам.
|
||||
- **deferred** — «Позже» паркует задачу; принимает те же команды, что и
|
||||
`review`, и возвращается в поверхность ревью по любому действию.
|
||||
- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем. Коллизия
|
||||
цели возвращает в review, ошибка ФС → failed. См.
|
||||
[architecture.md](architecture.md) → «Раскладка файлов».
|
||||
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
|
||||
(опц., см. [architecture.md](architecture.md) → «Пересканирование
|
||||
Jellyfin»); доступен **Undo** → `reverted` (убрать созданные ссылки).
|
||||
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
|
||||
(ретраибельна); «Отклонить».
|
||||
- **reverted / cancelled → recognizing** — «Привязать заново»: после
|
||||
отката или отклонения можно перезапустить распознавание для той же
|
||||
раздачи. Перепривязка всегда идёт через review с ручным подтверждением
|
||||
(авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в
|
||||
qBittorrent.
|
||||
|
||||
Все переходы и команды идут через `worker` под per-download блокировкой —
|
||||
два транспорта не гонятся за одно состояние. Состояние персистентно в
|
||||
SQLite; `worker` периодически сверяет qBittorrent с БД и **усыновляет**
|
||||
раздачи с нашей категорией (`qbittorrent.category`) **или** тегом
|
||||
(`qbittorrent.tag`), которых ещё нет в БД, заводя для них задачу в
|
||||
состоянии `downloading`. Категория ставится на добавляемые нами раздачи
|
||||
(push, задаёт savepath); тег позволяет подхватить уже существующую
|
||||
раздачу, не трогая её категорию и файлы (pull).
|
||||
|
||||
## Завершение в qBittorrent
|
||||
|
||||
`worker` опрашивает qBittorrent и сопоставляет его состояния с нашими:
|
||||
|
||||
- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`stoppedUP`/
|
||||
`queuedUP`/`forcedUP` (имена `paused*`/`stopped*` различаются между qBit
|
||||
v4 и v5 — поддержаны оба).
|
||||
- **переходное, ждём:** `moving`/`checkingUP`/`checkingResumeData`/
|
||||
`allocating` — остаёмся в `downloading`, пока qBit не закончит перенос/
|
||||
проверку (готовность не объявляем, даже если флаги «UP»).
|
||||
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/
|
||||
`queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`.
|
||||
- **застряло/ошибка по таймауту:** `metaDL`/`forcedMetaDL` дольше
|
||||
`magnet_timeout` → `failed`; `stalledDL` дольше `stuck_after` → `stuck`
|
||||
(восстановимо ретраем). Возраст считаем от создания задачи.
|
||||
- **ошибка:** `error`/`missingFiles` → `failed`.
|
||||
|
||||
Пути файлов берём из API (`save_path` + относительные имена из
|
||||
`/torrents/files`, уже включающие корневую папку торрента), не из
|
||||
константы (обычно это уже хост-путь). «Incomplete»-каталог в
|
||||
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
|
||||
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
|
||||
`moving` — дожидаемся окончания переноса и только потом берём финальный
|
||||
путь). Подробнее о путях и песочнице — [architecture.md](architecture.md)
|
||||
→ «Пути и контейнеры».
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# TODO
|
||||
|
||||
Конкретные задачи на будущее, ранжированные по приоритету. Это не план
|
||||
реализации (он — в [drafts/roadmap.md](drafts/roadmap.md)) и не свалка
|
||||
идей ([drafts/ideas.md](drafts/ideas.md)): сюда попадает то, что уже решили
|
||||
сделать, но ещё не сделали. Принятое и реализованное переезжает в
|
||||
`docs/specs`/`docs/adr`.
|
||||
|
||||
Приоритет — грубая оценка «ценность / стоимость», не обязательство к
|
||||
порядку.
|
||||
|
||||
## Высокий
|
||||
|
||||
### Проблема второго сезона
|
||||
|
||||
Если первый сезон сериала уже разложен, а мы добавляем второй/третий/…,
|
||||
распознавание должно привязать новый сезон к **тому же** названию и папке,
|
||||
а не завести рядом почти одинаковую вторую папку. Ключ — стабильный
|
||||
`provider_id`: один и тот же `[tvdbid-…]` → одна папка сериала, новые
|
||||
`Season NN` доливаются внутрь. Нужно: при матче учитывать уже существующие
|
||||
в библиотеке сериалы (или прошлые распознавания с тем же провайдер-id) и
|
||||
склонять LLM/выбор кандидата к согласованности с ними.
|
||||
|
||||
Связано: [recognition.md](specs/recognition.md) (модель уверенности,
|
||||
матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка
|
||||
сериала с провайдер-id).
|
||||
|
||||
### Название из контекста при добавлении в qBittorrent
|
||||
|
||||
При создании magnet-загрузки передавать в qBittorrent человекочитаемое имя
|
||||
из контекста (если оно есть), чтобы в списке qBit не было безликих
|
||||
`rutracker-topic-6852853`. Небольшая задача с заметной отдачей в
|
||||
повседневной эксплуатации.
|
||||
|
||||
Связано: [architecture.md](specs/architecture.md) → «Транспорты», пакет
|
||||
`ingest`/`qbt`.
|
||||
|
||||
### Рассинхрон состояния с реальностью (удалённый торрент / файлы)
|
||||
|
||||
Состояние jellybit может разойтись с тем, что реально лежит на диске.
|
||||
Несколько сценариев разной остроты:
|
||||
|
||||
- **Жёсткий — удалён источник.** Раздачу удаляют (вручную или авто по
|
||||
достижении seed limit), и qBittorrent стирает скачанные файлы. Тогда
|
||||
хардлинк в библиотеке становится **последней** ссылкой на inode, и
|
||||
обычный `undo` (`unlink` цели + чистка пустых каталогов) сотрёт
|
||||
единственную копию насовсем — прямая потеря данных. Инвариант «источник
|
||||
неприкосновенен» молчаливо перестаёт держаться: источника уже нет.
|
||||
- **Мягкий — удалена цель.** Файлы убрали из библиотеки Jellyfin (вручную
|
||||
или из самого Jellyfin), а jellybit по-прежнему числит загрузку в
|
||||
`done`. Состояние врёт: ссылок уже нет, а сервис думает, что всё
|
||||
разложено.
|
||||
|
||||
Нужно продумать сверку записанного состояния (`file_link`, состояние
|
||||
загрузки) с фактом на ФС:
|
||||
|
||||
- как `worker` реагирует на исчезновение раздачи из qBittorrent
|
||||
(состояние/пометка загрузки);
|
||||
- как `undo` защищается, когда источник недоступен — например,
|
||||
отказываться удалять, если у целевого файла счётчик ссылок == 1 (нет
|
||||
второй копии) или исходный путь не существует, и явно об этом сообщать.
|
||||
Откат снимает **лишний** хардлинк, а не последнюю копию файла;
|
||||
- как ловить пропажу целевых файлов и отражать её в состоянии (напр.
|
||||
периодическая сверка или проверка при показе — «разложено, но файлов
|
||||
нет»), чтобы можно было осознанно перепривязать/переразложить.
|
||||
|
||||
Связано: [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md),
|
||||
[architecture.md](specs/architecture.md) → «Раскладка файлов» (undo,
|
||||
инвариант источника), [workflow.md](specs/workflow.md) (`done → reverted`).
|
||||
|
||||
## Средний
|
||||
|
||||
### Машина состояний на go-библиотеке
|
||||
|
||||
Сейчас FSM реализована вручную в `worker`. Выбрать подходящую go-библиотеку
|
||||
для описания воркфлоу/машины состояний и перевести переходы на неё — ради
|
||||
декларативности, проверяемости переходов и единого места правды. Кандидаты
|
||||
для оценки: `looplab/fsm`, `qmuntal/stateless` (и аналоги). Граф и переходы
|
||||
уже формализованы — переносим один в один.
|
||||
|
||||
Связано: [workflow.md](specs/workflow.md) (текущий граф состояний).
|
||||
|
||||
### Привязка уведомлений к источнику в ботах (мульти-бот)
|
||||
|
||||
Уведомления и запросы подтверждения должен получать тот, кто прислал
|
||||
загрузку: автор сообщения о новой раздаче — адресат пингов и ревью по ней.
|
||||
Транспортов-ботов может быть несколько (Telegram, в перспективе Matrix и
|
||||
др.); каждый адресует «своему» отправителю. Веб-интерфейс остаётся
|
||||
**единым для всех** и точкой правды по функциональности (боты — тонкие
|
||||
адаптеры над тем же ядром). Нужно: хранить у загрузки источник/транспорт и
|
||||
идентификатор отправителя, маршрутизировать пинги по нему.
|
||||
|
||||
Связано: [review-ux.md](specs/review-ux.md) (разделение труда транспортов,
|
||||
веб = точные правки), [architecture.md](specs/architecture.md) →
|
||||
«Транспорты».
|
||||
|
||||
### Добавление торрентов файлом/ссылкой — «единое окно»
|
||||
|
||||
Поддержать источники помимо magnet: `.torrent`-файл и URL (отдаём их в
|
||||
qBittorrent, без исходящих запросов на пользовательский URL — SSRF
|
||||
исключён). Идеал — одно поле «единого окна»: кидаем туда текст или файл, а
|
||||
сервис сам разбирает, что это (magnet / ссылка / .torrent / сообщение
|
||||
бота), и заводит загрузку.
|
||||
|
||||
Связано: [architecture.md](specs/architecture.md) → «Транспорты»
|
||||
(`source_type = magnet|torrent|url` уже в схеме), пакет `ingest` (сейчас
|
||||
поддержан только magnet).
|
||||
|
||||
## Низкий
|
||||
|
||||
### Многоступенчатая верификация привязки (тема для размышления)
|
||||
|
||||
Идея: несколько раз извлекать данные из раздачи и контекста разными
|
||||
промптами, искать в метабазах, затем сводить результаты в общий вердикт
|
||||
(голосование/консенсус) — выше точность ценой нескольких вызовов LLM и
|
||||
запросов к базам. Требует проработки: когда включать, как мерджить
|
||||
расхождения, стоимость/латентность.
|
||||
|
||||
Связано: [recognition.md](specs/recognition.md) (конвейер и модель
|
||||
уверенности).
|
||||
|
||||
### Современный Web-UI как PWA
|
||||
|
||||
Переделать веб-интерфейс в современное PWA-приложение (устанавливаемое,
|
||||
отзывчивое, удобное с телефона). Текущий server-rendered UI функционален,
|
||||
поэтому это улучшение, а не блокер; большой объём работы.
|
||||
|
||||
Связано: [review-ux.md](specs/review-ux.md) (веб = точные правки),
|
||||
пакет `httpapi`.
|
||||
@@ -4,6 +4,7 @@ go 1.26
|
||||
|
||||
require (
|
||||
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/middelink/go-parse-torrent-name v0.0.0-20190301154245-3ff4efacd4c4
|
||||
github.com/pelletier/go-toml/v2 v2.2.3
|
||||
|
||||
@@ -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-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-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/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
|
||||
@@ -17,6 +17,7 @@ type Config struct {
|
||||
Storage Storage `toml:"storage"`
|
||||
LLM LLM `toml:"llm"`
|
||||
Metadata Metadata `toml:"metadata"`
|
||||
Jellyfin Jellyfin `toml:"jellyfin"`
|
||||
Worker Worker `toml:"worker"`
|
||||
Recognition Recognition `toml:"recognition"`
|
||||
Telegram Telegram `toml:"telegram"`
|
||||
@@ -29,7 +30,12 @@ type QBittorrent struct {
|
||||
URL string `toml:"url"`
|
||||
Username string `toml:"username"`
|
||||
Password string `toml:"password"`
|
||||
// Category — категория для добавляемых jellybit раздач (push, savepath).
|
||||
Category string `toml:"category"`
|
||||
// Tag — метка для усыновления существующих раздач (pull, не трогает
|
||||
// категорию/savepath). Discovery подхватывает раздачи с этой категорией
|
||||
// ИЛИ этим тегом.
|
||||
Tag string `toml:"tag"`
|
||||
SavePath string `toml:"savepath"`
|
||||
PathMap map[string]string `toml:"path_map"`
|
||||
}
|
||||
@@ -61,9 +67,11 @@ type LLM struct {
|
||||
type Metadata struct {
|
||||
TMDB MetadataProvider `toml:"tmdb"`
|
||||
TVDB MetadataProvider `toml:"tvdb"`
|
||||
TVMaze MetadataProvider `toml:"tvmaze"` // без ключа, только сериалы
|
||||
}
|
||||
|
||||
// MetadataProvider — настройки одного провайдера метаданных.
|
||||
// MetadataProvider — настройки одного провайдера метаданных. У keyless-баз
|
||||
// (TVMaze) поле api_key не используется.
|
||||
type MetadataProvider struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
APIKey string `toml:"api_key"`
|
||||
@@ -71,6 +79,16 @@ type MetadataProvider struct {
|
||||
Timeout Duration `toml:"timeout"`
|
||||
}
|
||||
|
||||
// Jellyfin — пересканирование медиатеки после раскладки (опц.). Включается
|
||||
// конфигом; без него скан не дёргается.
|
||||
type Jellyfin struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
URL string `toml:"url"`
|
||||
APIKey string `toml:"api_key"`
|
||||
Proxy string `toml:"proxy"` // опц. HTTP-прокси
|
||||
Timeout Duration `toml:"timeout"`
|
||||
}
|
||||
|
||||
// Worker — параметры фонового цикла.
|
||||
type Worker struct {
|
||||
PollInterval Duration `toml:"poll_interval"`
|
||||
@@ -88,11 +106,16 @@ type Telegram struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Token string `toml:"token"`
|
||||
AllowedUserIDs []int64 `toml:"allowed_user_ids"`
|
||||
WebBaseURL string `toml:"web_base_url"` // для deep-link «открыть в вебе» (опц.)
|
||||
Proxy string `toml:"proxy"` // опц. HTTP-прокси для api.telegram.org
|
||||
}
|
||||
|
||||
// HTTP — параметры веб-сервера.
|
||||
type HTTP struct {
|
||||
Listen string `toml:"listen"`
|
||||
// TrustedSubnets — allowlist подсетей. ПОКА НЕ ПРИМЕНЯЕТСЯ: деплой только
|
||||
// в локальную сеть без доступа из интернета, поэтому middleware отложено
|
||||
// (см. architecture.md). Поле сохранено под будущую реализацию.
|
||||
TrustedSubnets []string `toml:"trusted_subnets"`
|
||||
}
|
||||
|
||||
@@ -143,6 +166,7 @@ func Default() *Config {
|
||||
TMDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
|
||||
TVDB: MetadataProvider{Timeout: Duration(10 * time.Second)},
|
||||
},
|
||||
Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)},
|
||||
Worker: Worker{
|
||||
PollInterval: Duration(5 * time.Second),
|
||||
StuckAfter: Duration(time.Hour),
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -85,10 +86,15 @@ func NewRouter(d Deps) (http.Handler, error) {
|
||||
r.Get("/review/{id}", s.handleReview)
|
||||
r.Post("/ui/downloads/{id}/apply", s.handleApply)
|
||||
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
|
||||
r.Post("/ui/downloads/{id}/rerecognize", s.handleRerecognize)
|
||||
r.Post("/ui/downloads/{id}/type", s.handleSetType)
|
||||
r.Post("/ui/downloads/{id}/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}/undo", s.handleUndo)
|
||||
r.Post("/ui/downloads/{id}/relink", s.handleRelink)
|
||||
|
||||
// REST API.
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
@@ -123,6 +129,7 @@ type downloadView struct {
|
||||
Terminal bool
|
||||
Reviewable bool // review/deferred — есть экран ревью
|
||||
Undoable bool // done — можно откатить раскладку
|
||||
Relinkable bool // reverted/cancelled — можно перепривязать заново
|
||||
}
|
||||
|
||||
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -262,6 +269,7 @@ func (s *server) apiCommand(w http.ResponseWriter, r *http.Request, cmd func(con
|
||||
return
|
||||
}
|
||||
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))
|
||||
return
|
||||
}
|
||||
@@ -300,6 +308,7 @@ func toView(d store.Download) downloadView {
|
||||
Terminal: d.State.IsTerminal(),
|
||||
Reviewable: d.State == store.StateReview || d.State == store.StateDeferred,
|
||||
Undoable: d.State == store.StateDone,
|
||||
Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +341,9 @@ func errJSON(err error) map[string]string {
|
||||
return map[string]string{"error": err.Error()}
|
||||
}
|
||||
|
||||
// requestLogger пишет структурированный лог по каждому запросу.
|
||||
// requestLogger пишет структурированный лог по каждому запросу. Частые
|
||||
// служебные запросы (healthcheck, GET-страницы веб-UI с авто-рефрешем) пишем
|
||||
// на DEBUG, чтобы не зашумлять INFO; мутации и REST API остаются на INFO.
|
||||
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -341,7 +352,7 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
|
||||
logger.Info("http request",
|
||||
logger.Log(r.Context(), requestLogLevel(r), "http request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.Status(),
|
||||
@@ -352,3 +363,17 @@ func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// requestLogLevel понижает уровень для частых служебных запросов: healthcheck
|
||||
// и GET-страницы веб-UI (список авто-рефрешится каждые 5 с). Мутации и REST
|
||||
// API (`/api/...`) остаются на INFO.
|
||||
func requestLogLevel(r *http.Request) slog.Level {
|
||||
switch {
|
||||
case r.URL.Path == "/healthz":
|
||||
return slog.LevelDebug
|
||||
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api"):
|
||||
return slog.LevelDebug
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapi_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -187,9 +188,14 @@ type fakeReviewer struct {
|
||||
refined map[int64]string
|
||||
typed map[int64]string
|
||||
ignored map[int64]string
|
||||
chosen map[int64]int64
|
||||
providerSet map[int64]string
|
||||
applied []int64
|
||||
deferred []int64
|
||||
undone []int64
|
||||
relinked []int64
|
||||
rerecognized []int64
|
||||
cleared []int64
|
||||
}
|
||||
|
||||
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
|
||||
@@ -231,6 +237,32 @@ func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
|
||||
f.undone = append(f.undone, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Relink(_ context.Context, id int64) error {
|
||||
f.relinked = append(f.relinked, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) Rerecognize(_ context.Context, id int64) error {
|
||||
f.rerecognized = append(f.rerecognized, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
|
||||
if f.chosen == nil {
|
||||
f.chosen = map[int64]int64{}
|
||||
}
|
||||
f.chosen[id] = candidateID
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) SetProviderID(_ context.Context, id int64, provider, providerID string) error {
|
||||
if f.providerSet == nil {
|
||||
f.providerSet = map[int64]string{}
|
||||
}
|
||||
f.providerSet[id] = provider + ":" + providerID
|
||||
return nil
|
||||
}
|
||||
func (f *fakeReviewer) ClearProvider(_ context.Context, id int64) error {
|
||||
f.cleared = append(f.cleared, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func seriesReviewData() *worker.ReviewData {
|
||||
s, e := 2, 1
|
||||
@@ -248,6 +280,11 @@ func seriesReviewData() *worker.ReviewData {
|
||||
Preview: []layout.Link{
|
||||
{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{"второй сезон"},
|
||||
}
|
||||
}
|
||||
@@ -274,13 +311,55 @@ func TestReviewRenders(t *testing.T) {
|
||||
t.Fatalf("status = %d", resp.StatusCode)
|
||||
}
|
||||
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
|
||||
"Season 02", "Применить", "Уточнить"} {
|
||||
"Season 02", "Применить", "Уточнить",
|
||||
"База метаданных", "269613", "выбрать", "Без базы"} {
|
||||
if !strings.Contains(string(body), 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) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
@@ -375,3 +454,31 @@ func TestUndoAndDefer(t *testing.T) {
|
||||
t.Errorf("undo=%v defer=%v", rv.undone, rv.deferred)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelink(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/relink", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rv.relinked) != 1 || rv.relinked[0] != 1 {
|
||||
t.Errorf("relinked = %v, want [1]", rv.relinked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerecognize(t *testing.T) {
|
||||
rv := &fakeReviewer{data: seriesReviewData()}
|
||||
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
|
||||
Reader: &fakeReader{}, Reviewer: rv})
|
||||
cl := noRedirectClient()
|
||||
|
||||
if _, err := cl.Post(srv.URL+"/ui/downloads/1/rerecognize", "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rv.rerecognized) != 1 || rv.rerecognized[0] != 1 {
|
||||
t.Errorf("rerecognized = %v, want [1]", rv.rerecognized)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRequestLogLevel(t *testing.T) {
|
||||
cases := []struct {
|
||||
method, path string
|
||||
want slog.Level
|
||||
}{
|
||||
{"GET", "/healthz", slog.LevelDebug}, // healthcheck — тихо
|
||||
{"GET", "/", slog.LevelDebug}, // список (авто-рефреш)
|
||||
{"GET", "/review/1", slog.LevelDebug}, // страница ревью
|
||||
{"GET", "/api/downloads", slog.LevelInfo}, // REST API — на INFO
|
||||
{"POST", "/ui/downloads/1/apply", slog.LevelInfo}, // мутация — на INFO
|
||||
{"POST", "/api/downloads", slog.LevelInfo}, // приём — на INFO
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := httptest.NewRequest(c.method, c.path, nil)
|
||||
if got := requestLogLevel(r); got != c.want {
|
||||
t.Errorf("%s %s: level=%v, want %v", c.method, c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,11 @@ type Reviewer interface {
|
||||
IgnoreFile(ctx context.Context, id int64, src string) error
|
||||
Defer(ctx context.Context, id int64) error
|
||||
Undo(ctx context.Context, id int64) error
|
||||
Relink(ctx context.Context, id int64) error
|
||||
Rerecognize(ctx context.Context, id int64) error
|
||||
ChooseCandidate(ctx context.Context, id, candidateID int64) error
|
||||
SetProviderID(ctx context.Context, id int64, provider, providerID string) error
|
||||
ClearProvider(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
// --- Представление страницы ревью ---
|
||||
@@ -44,6 +49,8 @@ type reviewView struct {
|
||||
Files []reviewFileView
|
||||
Preview []string
|
||||
HasPlan bool
|
||||
NoBase bool // выбрано «без базы»
|
||||
Candidates []candidateView
|
||||
}
|
||||
|
||||
type reviewFileView struct {
|
||||
@@ -54,6 +61,15 @@ type reviewFileView struct {
|
||||
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) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
@@ -87,9 +103,12 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
view.OriginalTitle = rd.Plan.OriginalTitle
|
||||
view.Year = rd.Plan.Year
|
||||
view.Reasons = rec.ReasonList()
|
||||
if rec.Provider.Valid && rec.Provider.String != "none" {
|
||||
view.Provider = rec.Provider.String
|
||||
view.ProviderID = rec.ProviderID.String
|
||||
switch rd.Provider {
|
||||
case "", "none":
|
||||
view.NoBase = rd.Provider == "none"
|
||||
default:
|
||||
view.Provider = rd.Provider
|
||||
view.ProviderID = rd.ProviderID
|
||||
}
|
||||
if rec.Confidence.Valid {
|
||||
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
|
||||
@@ -104,6 +123,16 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
view.HasPlan = len(rd.Plan.Files) > 0
|
||||
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 {
|
||||
view.Preview = append(view.Preview, l.Dst)
|
||||
@@ -124,6 +153,7 @@ func (s *server) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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())
|
||||
return
|
||||
}
|
||||
@@ -137,6 +167,12 @@ func (s *server) handleRefine(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleRerecognize(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
return s.deps.Reviewer.Rerecognize(ctx, id)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleSetType(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
_ = r.ParseForm()
|
||||
@@ -151,6 +187,32 @@ func (s *server) handleIgnore(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleChooseCandidate(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
_ = r.ParseForm()
|
||||
candidateID, err := strconv.ParseInt(r.PostForm.Get("candidate_id"), 10, 64)
|
||||
if err != nil {
|
||||
return errInvalidCandidate
|
||||
}
|
||||
return s.deps.Reviewer.ChooseCandidate(ctx, id, candidateID)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleSetProvider(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
_ = r.ParseForm()
|
||||
return s.deps.Reviewer.SetProviderID(ctx, id, r.PostForm.Get("provider"), r.PostForm.Get("provider_id"))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleNoBase(w http.ResponseWriter, r *http.Request) {
|
||||
s.reviewAction(w, r, func(ctx context.Context, id int64) error {
|
||||
return s.deps.Reviewer.ClearProvider(ctx, id)
|
||||
})
|
||||
}
|
||||
|
||||
var errInvalidCandidate = errors.New("некорректный id кандидата")
|
||||
|
||||
func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
@@ -158,6 +220,7 @@ func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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())
|
||||
return
|
||||
}
|
||||
@@ -171,6 +234,23 @@ func (s *server) handleUndo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := s.deps.Reviewer.Undo(r.Context(), id); err != nil {
|
||||
s.deps.Logger.Warn("review action failed", "action", "undo", "id", id, "err", err)
|
||||
redirectErr(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleRelink повторно привязывает откатанную задачу: перезапускает
|
||||
// распознавание, задача пройдёт recognizing → review для подтверждения.
|
||||
func (s *server) handleRelink(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := pathID(r)
|
||||
if err != nil {
|
||||
redirectErr(w, r, "некорректный id")
|
||||
return
|
||||
}
|
||||
if err := s.deps.Reviewer.Relink(r.Context(), id); err != nil {
|
||||
s.deps.Logger.Warn("review action failed", "action", "relink", "id", id, "err", err)
|
||||
redirectErr(w, r, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -186,6 +266,8 @@ func (s *server) reviewAction(w http.ResponseWriter, r *http.Request, fn func(co
|
||||
return
|
||||
}
|
||||
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())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -101,6 +101,8 @@ func (s *Service) Ingest(ctx context.Context, req Request) (Result, error) {
|
||||
SavePath: s.cfg.SavePath,
|
||||
})
|
||||
if addErr != nil {
|
||||
s.log.Warn("ingest: qbittorrent add failed, marking download failed",
|
||||
"download_id", id, "infohash", info.Infohash, "err", addErr)
|
||||
// Задача уже в БД — помечаем failed, чтобы worker её не подхватил.
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// Package jellyfin — минимальный клиент Jellyfin для пересканирования
|
||||
// медиатеки после успешной раскладки. Единственная задача: дёрнуть скан
|
||||
// всех библиотек (POST /Library/Refresh), чтобы новые хардлинки быстрее
|
||||
// появились в проигрывателе. В духе сервиса — без зоопарка вызовов.
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultTimeout = 10 * time.Second
|
||||
|
||||
// Config — подключение к Jellyfin.
|
||||
type Config struct {
|
||||
URL string
|
||||
APIKey string
|
||||
Proxy string // опц. HTTP-прокси
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Client — клиент Jellyfin API.
|
||||
type Client struct {
|
||||
base string
|
||||
apiKey string
|
||||
hc *http.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает клиент с опц. прокси. logger nil → slog.Default().
|
||||
func New(cfg Config, logger *slog.Logger) (*Client, error) {
|
||||
base, err := url.Parse(strings.TrimRight(cfg.URL, "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jellyfin: parse url %q: %w", cfg.URL, err)
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
transport := http.DefaultTransport
|
||||
if cfg.Proxy != "" {
|
||||
pu, perr := url.Parse(cfg.Proxy)
|
||||
if perr != nil {
|
||||
return nil, fmt.Errorf("jellyfin: parse proxy %q: %w", cfg.Proxy, perr)
|
||||
}
|
||||
// Клонируем дефолтный транспорт (dial/TLS-таймауты, keep-alive), а не
|
||||
// собираем голый — как в metadata-клиенте.
|
||||
t := http.DefaultTransport.(*http.Transport).Clone()
|
||||
t.Proxy = http.ProxyURL(pu)
|
||||
transport = t
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Client{
|
||||
base: base.String(),
|
||||
apiKey: cfg.APIKey,
|
||||
hc: &http.Client{Timeout: timeout, Transport: transport},
|
||||
log: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshLibraries запускает скан всех библиотек Jellyfin
|
||||
// (POST /Library/Refresh). Скан инкрементальный — полный дёшев, поэтому
|
||||
// точечный скан конкретной папки не делаем (сложнее, не в духе сервиса).
|
||||
// Ответ при успехе — 204 No Content.
|
||||
func (c *Client) RefreshLibraries(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/Library/Refresh", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jellyfin: build request: %w", err)
|
||||
}
|
||||
req.Header.Set("X-Emby-Token", c.apiKey)
|
||||
|
||||
start := time.Now()
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jellyfin: refresh: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("jellyfin: refresh: status %d body %q",
|
||||
resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
c.log.Info("jellyfin: library refresh triggered", "duration", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRefreshLibraries_OK(t *testing.T) {
|
||||
var gotPath, gotToken, gotMethod string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
gotToken = r.Header.Get("X-Emby-Token")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{URL: srv.URL, APIKey: "secret"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if err := c.RefreshLibraries(context.Background()); err != nil {
|
||||
t.Fatalf("RefreshLibraries: %v", err)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Errorf("method = %q, want POST", gotMethod)
|
||||
}
|
||||
if gotPath != "/Library/Refresh" {
|
||||
t.Errorf("path = %q, want /Library/Refresh", gotPath)
|
||||
}
|
||||
if gotToken != "secret" {
|
||||
t.Errorf("token = %q, want secret", gotToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshLibraries_TrimsTrailingSlash(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{URL: srv.URL + "/", APIKey: "k"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if err := c.RefreshLibraries(context.Background()); err != nil {
|
||||
t.Fatalf("RefreshLibraries: %v", err)
|
||||
}
|
||||
if gotPath != "/Library/Refresh" {
|
||||
t.Errorf("path = %q, want /Library/Refresh (без двойного слеша)", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshLibraries_ErrorStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := New(Config{URL: srv.URL, APIKey: "bad"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
if err := c.RefreshLibraries(context.Background()); err == nil {
|
||||
t.Fatal("ожидали ошибку на 401, получили nil")
|
||||
}
|
||||
}
|
||||
+102
-17
@@ -15,7 +15,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
@@ -49,7 +51,7 @@ const (
|
||||
|
||||
// PlanFile — один файл к раскладке.
|
||||
type PlanFile struct {
|
||||
Src string // абсолютный путь источника (content dir + относительное имя)
|
||||
Src string // абсолютный путь источника (save_path + относительное имя)
|
||||
Role Role
|
||||
Season *int // для сериала
|
||||
Episode *int // для сериала
|
||||
@@ -86,10 +88,12 @@ type Layouter struct {
|
||||
movies string
|
||||
series string
|
||||
dirMode os.FileMode
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New собирает раскладчик. Корни нормализуются (filepath.Clean).
|
||||
func New(cfg Config) (*Layouter, error) {
|
||||
// New собирает раскладчик. Корни нормализуются (filepath.Clean). logger nil →
|
||||
// slog.Default().
|
||||
func New(cfg Config, logger *slog.Logger) (*Layouter, error) {
|
||||
if cfg.MoviesDir == "" || cfg.SeriesDir == "" {
|
||||
return nil, fmt.Errorf("layout: movies/series dirs required")
|
||||
}
|
||||
@@ -97,10 +101,14 @@ func New(cfg Config) (*Layouter, error) {
|
||||
if mode == 0 {
|
||||
mode = 0o755
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Layouter{
|
||||
movies: filepath.Clean(cfg.MoviesDir),
|
||||
series: filepath.Clean(cfg.SeriesDir),
|
||||
dirMode: mode,
|
||||
log: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -112,7 +120,7 @@ func (l *Layouter) root(t MediaType) (string, error) {
|
||||
case Series:
|
||||
return l.series, nil
|
||||
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)
|
||||
}
|
||||
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})
|
||||
}
|
||||
if len(links) == 0 {
|
||||
return nil, fmt.Errorf("layout: план не дал ни одной ссылки")
|
||||
return nil, fmt.Errorf("layout: plan produced no links")
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
@@ -180,7 +188,7 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki
|
||||
return "", "", 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
|
||||
if f.EpisodeEnd != nil {
|
||||
@@ -203,7 +211,8 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki
|
||||
type LinkStatus string
|
||||
|
||||
const (
|
||||
StatusLinked LinkStatus = "linked" // ссылка создана
|
||||
StatusLinked LinkStatus = "linked" // хардлинк создан
|
||||
StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк)
|
||||
StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно
|
||||
StatusCollision LinkStatus = "collision" // цель занята другим файлом
|
||||
)
|
||||
@@ -219,7 +228,8 @@ var ErrCollision = errors.New("layout: target collision")
|
||||
|
||||
// Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя
|
||||
// доводит начатое. При коллизии (цель занята чужим файлом) возвращает
|
||||
// ErrCollision, не перезаписывая. EXDEV (разные ФС) — явная ошибка.
|
||||
// ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС
|
||||
// не поддерживает link) — фолбэк на копирование файла с предупреждением в лог.
|
||||
func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) {
|
||||
results := make([]Result, 0, len(links))
|
||||
for _, ln := range links {
|
||||
@@ -228,23 +238,28 @@ func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) {
|
||||
root = l.series
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
l.log.Error("layout: link failed",
|
||||
"src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "err", 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})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// linkOne создаёт одну ссылку, разбирая «уже существует».
|
||||
func linkOne(src, dst string) (LinkStatus, error) {
|
||||
// linkOne создаёт одну ссылку, разбирая «уже существует» и невозможность
|
||||
// хардлинка (фолбэк на копирование).
|
||||
func (l *Layouter) linkOne(src, dst string) (LinkStatus, error) {
|
||||
err := os.Link(src, dst)
|
||||
if err == nil {
|
||||
return StatusLinked, nil
|
||||
@@ -257,14 +272,82 @@ func linkOne(src, dst string) (LinkStatus, error) {
|
||||
if same {
|
||||
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) {
|
||||
return "", fmt.Errorf("layout: hardlink через границу ФС (%q → %q): %w", src, dst, err)
|
||||
if hardlinkUnsupported(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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func sameFile(src, dst string) (bool, error) {
|
||||
si, err := os.Stat(src)
|
||||
@@ -289,15 +372,17 @@ func (l *Layouter) Undo(_ context.Context, links []Link) (int, error) {
|
||||
root = l.series
|
||||
}
|
||||
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 errors.Is(err, fs.ErrNotExist) {
|
||||
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)
|
||||
}
|
||||
removed++
|
||||
l.log.Debug("layout: link removed", "dst", ln.Dst)
|
||||
pruneEmptyDirs(filepath.Dir(ln.Dst), root)
|
||||
}
|
||||
return removed, nil
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ func newFixture(t *testing.T) fixture {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
l, err := New(Config{MoviesDir: movies, SeriesDir: series})
|
||||
l, err := New(Config{MoviesDir: movies, SeriesDir: series}, nil)
|
||||
if err != nil {
|
||||
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) {
|
||||
f := newFixture(t)
|
||||
outside := filepath.Join(f.downloads, "victim.mkv")
|
||||
|
||||
@@ -32,7 +32,7 @@ func sanitizeComponent(s string) string {
|
||||
func titleYear(title string, year int) (string, error) {
|
||||
t := sanitizeComponent(title)
|
||||
if t == "" {
|
||||
return "", fmt.Errorf("layout: пустое название после санитизации (%q)", title)
|
||||
return "", fmt.Errorf("layout: empty title after sanitization (%q)", title)
|
||||
}
|
||||
if year > 0 {
|
||||
return fmt.Sprintf("%s (%d)", t, year), nil
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestIntegration_OpenAICompat(t *testing.T) {
|
||||
APIKey: key,
|
||||
Model: model,
|
||||
Timeout: 90 * time.Second,
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
+8
-4
@@ -14,6 +14,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -74,13 +75,16 @@ type Config struct {
|
||||
// ErrUnknownType — запрошенный [llm].type не поддерживается.
|
||||
var ErrUnknownType = errors.New("llm: unknown provider type")
|
||||
|
||||
// New собирает провайдер по дискриминатору cfg.Type.
|
||||
func New(cfg Config) (Provider, error) {
|
||||
// New собирает провайдер по дискриминатору cfg.Type. logger nil → slog.Default().
|
||||
func New(cfg Config, logger *slog.Logger) (Provider, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
switch cfg.Type {
|
||||
case "openai-compat":
|
||||
return newOpenAICompat(cfg)
|
||||
return newOpenAICompat(cfg, logger)
|
||||
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:
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnknownType, cfg.Type)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func newTestProvider(t *testing.T, baseURL, apiKey string) *openAICompat {
|
||||
BaseURL: baseURL,
|
||||
APIKey: apiKey,
|
||||
Model: "test-model",
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newOpenAICompat: %v", err)
|
||||
}
|
||||
@@ -215,22 +215,22 @@ func TestComplete_EmptyMessages(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")
|
||||
}
|
||||
if _, err := New(Config{Type: ""}); err == nil {
|
||||
if _, err := New(Config{Type: ""}, nil); err == nil {
|
||||
t.Fatal("want error for empty type")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+22
-1
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -26,10 +27,11 @@ type openAICompat struct {
|
||||
apiKey string
|
||||
model string
|
||||
retryWait time.Duration // базовая пауза между ретраями (0 в тестах)
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// newOpenAICompat собирает клиент из конфига.
|
||||
func newOpenAICompat(cfg Config) (*openAICompat, error) {
|
||||
func newOpenAICompat(cfg Config, logger *slog.Logger) (*openAICompat, error) {
|
||||
if cfg.BaseURL == "" {
|
||||
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)}
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &openAICompat{
|
||||
endpoint: strings.TrimRight(cfg.BaseURL, "/") + "/chat/completions",
|
||||
hc: &http.Client{Timeout: timeout, Transport: transport},
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
retryWait: baseRetryWait,
|
||||
log: logger,
|
||||
}, 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)
|
||||
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
|
||||
}
|
||||
lastErr = err
|
||||
if !retryable {
|
||||
c.log.Error("llm: request failed (non-retryable)",
|
||||
"model", c.model, "attempt", attempt, "duration", time.Since(start), "err", 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -24,7 +25,12 @@ func newHTTPClient(proxy string, timeout time.Duration) (*http.Client, error) {
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
@@ -33,7 +39,7 @@ const maxBody = 4 << 20 // 4 MiB — потолок на тело ответа
|
||||
|
||||
// getJSON выполняет GET и декодирует JSON-ответ в out. headers — опц.
|
||||
// дополнительные заголовки (напр. 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)
|
||||
if err != nil {
|
||||
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 {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return doJSON(hc, req, out)
|
||||
return doJSON(hc, log, req, out)
|
||||
}
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
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("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)
|
||||
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)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
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)
|
||||
}
|
||||
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))
|
||||
}
|
||||
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))
|
||||
}
|
||||
log.Debug("metadata: request ok",
|
||||
"method", req.Method, "host", req.URL.Host, "path", req.URL.Path,
|
||||
"duration", time.Since(start))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,45 @@ import (
|
||||
"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. По умолчанию
|
||||
// пропускается; включается ключом:
|
||||
//
|
||||
@@ -18,7 +57,7 @@ func TestIntegration_TVDB(t *testing.T) {
|
||||
if key == "" {
|
||||
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 {
|
||||
t.Fatalf("NewTVDB: %v", err)
|
||||
}
|
||||
|
||||
@@ -25,12 +25,19 @@ type Query struct {
|
||||
}
|
||||
|
||||
// Candidate — результат поиска: официальный id и каноническое имя.
|
||||
//
|
||||
// ID — нативный id провайдера (по нему запрашиваются SeasonEpisodeCounts).
|
||||
// TagProvider/TagID — опц. внешний id для имени папки Jellyfin: напр. TVMaze
|
||||
// ищет без ключа, но отдаёт TVDB/IMDb-id во внешних ссылках, и тег ставим
|
||||
// привычный ([tvdbid-…]). Пусто → тег берётся из Provider/ID.
|
||||
type Candidate struct {
|
||||
Provider string // "tmdb" | "tvdb"
|
||||
Provider string // "tmdb" | "tvdb" | "tvmaze"
|
||||
ID string
|
||||
Title string
|
||||
OriginalTitle string
|
||||
Year int
|
||||
TagProvider string // напр. "tvdb"/"imdb" (опц.)
|
||||
TagID string
|
||||
}
|
||||
|
||||
// Provider — одна база метаданных.
|
||||
|
||||
@@ -3,6 +3,7 @@ package metadata
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -25,10 +26,11 @@ type TMDB struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
hc *http.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewTMDB собирает клиент TMDB.
|
||||
func NewTMDB(cfg TMDBConfig) (*TMDB, error) {
|
||||
// NewTMDB собирает клиент TMDB. logger nil → slog.Default().
|
||||
func NewTMDB(cfg TMDBConfig, logger *slog.Logger) (*TMDB, error) {
|
||||
if cfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("metadata: tmdb api_key required")
|
||||
}
|
||||
@@ -40,7 +42,10 @@ func NewTMDB(cfg TMDBConfig) (*TMDB, error) {
|
||||
if base == "" {
|
||||
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" }
|
||||
@@ -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))
|
||||
}
|
||||
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
|
||||
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)
|
||||
}
|
||||
t.log.Debug("tmdb: search done", "title", q.Title, "results", len(resp.Results))
|
||||
|
||||
out := make([]Candidate, 0, len(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) {
|
||||
params := url.Values{"api_key": {t.apiKey}}
|
||||
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)
|
||||
}
|
||||
out := make(map[int]int, len(resp.Seasons))
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
func newTMDB(t *testing.T, url string) *TMDB {
|
||||
t.Helper()
|
||||
c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: url})
|
||||
c, err := NewTMDB(TMDBConfig{APIKey: "k", BaseURL: url}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTMDB: %v", err)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestTMDB_ErrorStatus(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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -30,13 +31,14 @@ type TVDB struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
hc *http.Client
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
token string
|
||||
}
|
||||
|
||||
// NewTVDB собирает клиент TVDB.
|
||||
func NewTVDB(cfg TVDBConfig) (*TVDB, error) {
|
||||
// NewTVDB собирает клиент TVDB. logger nil → slog.Default().
|
||||
func NewTVDB(cfg TVDBConfig, logger *slog.Logger) (*TVDB, error) {
|
||||
if cfg.APIKey == "" {
|
||||
return nil, fmt.Errorf("metadata: tvdb api_key required")
|
||||
}
|
||||
@@ -48,7 +50,10 @@ func NewTVDB(cfg TVDBConfig) (*TVDB, error) {
|
||||
if base == "" {
|
||||
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" }
|
||||
@@ -65,7 +70,8 @@ func (t *TVDB) login(ctx context.Context) (string, error) {
|
||||
Token string `json:"token"`
|
||||
} `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 {
|
||||
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
|
||||
}
|
||||
if status == http.StatusUnauthorized {
|
||||
t.log.Warn("tvdb: token expired, re-login", "path", path)
|
||||
t.mu.Lock()
|
||||
t.token = "" // сбрасываем протухший токен
|
||||
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("Accept", "application/json")
|
||||
start := time.Now()
|
||||
resp, err := t.hc.Do(req)
|
||||
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)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -143,10 +156,12 @@ func (t *TVDB) Search(ctx context.Context, q Query) ([]Candidate, error) {
|
||||
if q.Year > 0 {
|
||||
params.Set("year", strconv.Itoa(q.Year))
|
||||
}
|
||||
t.log.Debug("tvdb: search", "type", q.Type, "title", q.Title, "year", q.Year)
|
||||
var resp tvdbSearchResp
|
||||
if err := t.get(ctx, "/search?"+params.Encode(), &resp); err != nil {
|
||||
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))
|
||||
for _, r := range resp.Data {
|
||||
if r.TVDBID == "" {
|
||||
|
||||
@@ -52,7 +52,7 @@ func fakeTVDB(t *testing.T, logins *atomic.Int32) *httptest.Server {
|
||||
|
||||
func newTVDB(t *testing.T, url string) *TVDB {
|
||||
t.Helper()
|
||||
c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url})
|
||||
c, err := NewTVDB(TVDBConfig{APIKey: "k", BaseURL: url}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTVDB: %v", err)
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func TestTVDB_ReloginOn401(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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -12,6 +12,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
@@ -36,6 +37,7 @@ type Client struct {
|
||||
hc *http.Client
|
||||
user string
|
||||
pass string
|
||||
log *slog.Logger
|
||||
mu sync.Mutex // сериализует логин
|
||||
}
|
||||
|
||||
@@ -47,6 +49,7 @@ type Torrent struct {
|
||||
SavePath string `json:"save_path"`
|
||||
ContentPath string `json:"content_path"`
|
||||
Category string `json:"category"`
|
||||
Tags string `json:"tags"` // через запятую
|
||||
Progress float64 `json:"progress"`
|
||||
AmountLeft int64 `json:"amount_left"`
|
||||
AddedOn int64 `json:"added_on"`
|
||||
@@ -54,8 +57,11 @@ type Torrent struct {
|
||||
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 {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
@@ -70,8 +76,8 @@ type AddRequest struct {
|
||||
Paused bool
|
||||
}
|
||||
|
||||
// New создаёт клиент с собственным cookie-jar.
|
||||
func New(cfg Config) (*Client, error) {
|
||||
// New создаёт клиент с собственным cookie-jar. logger nil → slog.Default().
|
||||
func New(cfg Config, logger *slog.Logger) (*Client, error) {
|
||||
base, err := url.Parse(strings.TrimRight(cfg.URL, "/"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse qbittorrent url %q: %w", cfg.URL, err)
|
||||
@@ -84,11 +90,15 @@ func New(cfg Config) (*Client, error) {
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &Client{
|
||||
base: base,
|
||||
hc: &http.Client{Jar: jar, Timeout: timeout},
|
||||
user: cfg.Username,
|
||||
pass: cfg.Password,
|
||||
log: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -115,9 +125,12 @@ func (c *Client) login(ctx context.Context) error {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||
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",
|
||||
resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
c.log.Debug("qbittorrent: login ok", "user", c.user)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -134,6 +147,7 @@ func (c *Client) do(ctx context.Context, build func() (*http.Request, error)) (*
|
||||
}
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
_ = resp.Body.Close()
|
||||
c.log.Debug("qbittorrent: session expired (403), re-login")
|
||||
if err := c.login(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -195,8 +209,13 @@ func (c *Client) Add(ctx context.Context, ar AddRequest) error {
|
||||
resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
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.)")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
return nil, fmt.Errorf("decode qbittorrent info: %w", err)
|
||||
}
|
||||
c.log.Debug("qbittorrent: torrents fetched", "category", category, "count", len(ts))
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
// Files возвращает список файлов торрента (имена относительно content_path и
|
||||
// размеры). Нужен распознаванию как один из сигналов.
|
||||
// Files возвращает список файлов торрента (имена относительно save_path,
|
||||
// включая корневую папку для многофайловых раздач, и размеры). Нужен
|
||||
// распознаванию как один из сигналов; абсолютный путь — join(save_path, Name).
|
||||
func (c *Client) Files(ctx context.Context, hash string) ([]File, error) {
|
||||
resp, err := c.do(ctx, func() (*http.Request, error) {
|
||||
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 {
|
||||
return nil, fmt.Errorf("decode qbittorrent files: %w", err)
|
||||
}
|
||||
c.log.Debug("qbittorrent: files fetched", "hash", hash, "count", len(fs))
|
||||
return fs, nil
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ func fakeQBittorrent(t *testing.T, info string) *httptest.Server {
|
||||
|
||||
func newClient(t *testing.T, url string) *Client {
|
||||
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 {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func TestTorrents(t *testing.T) {
|
||||
|
||||
func TestLoginFailure(t *testing.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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestIntegration_RecognizeSeries(t *testing.T) {
|
||||
provider, err := llm.New(llm.Config{
|
||||
Type: "openai-compat", BaseURL: base, APIKey: key, Model: model,
|
||||
Timeout: 90 * time.Second,
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("llm.New: %v", err)
|
||||
}
|
||||
|
||||
@@ -8,14 +8,17 @@ import (
|
||||
"git.vakhrushev.me/av/jellybit/internal/metadata"
|
||||
)
|
||||
|
||||
// matchMetadata сверяет план с включёнными базами: ищет по названию+году и,
|
||||
// если ровно один кандидат уверенно совпадает (название и год), возвращает
|
||||
// матч с официальным id и каноническим именем. Несколько кандидатов или их
|
||||
// отсутствие → nil (тогда авто-раскладки не будет). Ошибки провайдера не
|
||||
// валят распознавание — просто нет матча.
|
||||
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
|
||||
// maxCandidates — потолок на число сохраняемых кандидатов для ручного выбора.
|
||||
const maxCandidates = 8
|
||||
|
||||
// matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
|
||||
// сильный матч — ровно один кандидат с совпадением названия и года (для него
|
||||
// тянем число серий и используем для авто), либо nil; (б) список кандидатов
|
||||
// из всех провайдеров (топ-N, дедуп) — чтобы человек мог выбрать в review,
|
||||
// когда сильного матча нет. Ошибки провайдера не валят распознавание.
|
||||
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
|
||||
if len(r.providers) == 0 {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
mt := metadata.Movie
|
||||
if plan.Type == MediaSeries {
|
||||
@@ -28,29 +31,69 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
|
||||
}
|
||||
matchTitles := normSet(plan.Title, plan.OriginalTitle)
|
||||
|
||||
var match *Match
|
||||
var candidates []metadata.Candidate
|
||||
seen := map[string]bool{}
|
||||
|
||||
for _, p := range r.providers {
|
||||
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
|
||||
if err != nil {
|
||||
r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err)
|
||||
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)
|
||||
if len(strong) != 1 {
|
||||
continue
|
||||
}
|
||||
c := strong[0]
|
||||
match := &Match{Provider: c.Provider, ProviderID: c.ID, Title: c.Title, Year: c.Year}
|
||||
match = r.buildMatch(ctx, p, strong[0], mt)
|
||||
}
|
||||
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 counts, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil {
|
||||
match.SeasonEpisodeCounts = counts
|
||||
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", cerr)
|
||||
r.log.Warn("recognize: episode counts failed", "provider", p.Name(), "id", c.ID, "err", err)
|
||||
}
|
||||
}
|
||||
return match
|
||||
prov, pid := CandidateTag(c)
|
||||
return &Match{
|
||||
Provider: prov,
|
||||
ProviderID: pid,
|
||||
Title: c.Title,
|
||||
Year: c.Year,
|
||||
SeasonEpisodeCounts: counts,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CandidateTag — провайдер и id для тега папки Jellyfin: внешний (из
|
||||
// TagProvider/TagID, напр. TVMaze → tvdb/imdb), если есть, иначе сам провайдер
|
||||
// поиска. Используется и в матче, и при сохранении кандидатов.
|
||||
func CandidateTag(c metadata.Candidate) (provider, id string) {
|
||||
if c.TagProvider != "" {
|
||||
return c.TagProvider, c.TagID
|
||||
}
|
||||
return c.Provider, c.ID
|
||||
}
|
||||
|
||||
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestMatchMetadata_SingleStrong(t *testing.T) {
|
||||
{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m := r.matchMetadata(context.Background(),
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
|
||||
if m == nil {
|
||||
t.Fatal("expected match")
|
||||
@@ -61,16 +61,60 @@ func TestMatchMetadata_AmbiguousNoMatch(t *testing.T) {
|
||||
{ID: "2", Title: "Fargo", Year: 2014},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
if m := r.matchMetadata(context.Background(),
|
||||
if m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil {
|
||||
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) {
|
||||
p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
|
||||
r := recognizerWith(p)
|
||||
if m := r.matchMetadata(context.Background(),
|
||||
if m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil {
|
||||
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},
|
||||
}}
|
||||
r := recognizerWith(p)
|
||||
m := r.matchMetadata(context.Background(),
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "Léon", Year: 1994})
|
||||
if m == nil || m.ProviderID != "1" {
|
||||
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) {
|
||||
p := &fakeProvider{
|
||||
candidates: []metadata.Candidate{{ID: "60622", Title: "Fargo", Year: 2014}},
|
||||
counts: map[int]int{1: 10, 2: 10},
|
||||
}
|
||||
r := recognizerWith(p)
|
||||
m := r.matchMetadata(context.Background(),
|
||||
m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
|
||||
if m == nil || m.SeasonEpisodeCounts[1] != 10 {
|
||||
t.Errorf("counts not fetched: %+v", m)
|
||||
@@ -104,7 +172,7 @@ func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
|
||||
func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
|
||||
p := &fakeProvider{searchErr: errors.New("upstream down")}
|
||||
r := recognizerWith(p)
|
||||
if m := r.matchMetadata(context.Background(),
|
||||
if m, _ := r.matchMetadata(context.Background(),
|
||||
Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
||||
"provider_hint": "строка для поиска в базе (НЕ id)",
|
||||
"files": [
|
||||
{
|
||||
"src": "путь файла РОВНО как в списке ниже",
|
||||
"src": "путь файла из списка ниже, БЕЗ размера в скобках в конце строки",
|
||||
"role": "main" | "episode" | "subtitle" | "extra" | "sample" | "ignore",
|
||||
"season": число или null,
|
||||
"episode": число или null
|
||||
@@ -50,7 +50,8 @@ const schemaText = `Схема ответа (строгий JSON, без markdow
|
||||
- "files" покрывает каждый значимый файл; семплы/мусор помечай ролью "sample"/"ignore".
|
||||
- Для сериала каждой серии — отдельный файл с role "episode" и заполненными season и episode.
|
||||
- Для фильма ровно один основной видеофайл role "main".
|
||||
- Поле src копируй ДОСЛОВНО из списка файлов; не выдумывай и не нормализуй пути.
|
||||
- Поле src — это путь файла из списка, скопированный дословно, но БЕЗ размера
|
||||
«(…)» в конце строки; не выдумывай и не нормализуй пути.
|
||||
- Внешние субтитры — role "subtitle".`
|
||||
|
||||
const systemPrompt = `Ты распознаёшь медиа-раздачи для медиатеки Jellyfin: по имени торрента,
|
||||
@@ -133,14 +134,15 @@ func writeFileList(b *strings.Builder, files []File, maxFiles int) {
|
||||
}
|
||||
b.WriteString("Файлы (")
|
||||
b.WriteString(strconv.Itoa(n))
|
||||
b.WriteString(", поле src — это точные пути отсюда):\n")
|
||||
b.WriteString("). В src копируй ТОЛЬКО путь — текст после номера и до размера ")
|
||||
b.WriteString("в скобках; размер «(…)» в конце строки в src НЕ включай:\n")
|
||||
for i := 0; i < shown; i++ {
|
||||
b.WriteString(strconv.Itoa(i + 1))
|
||||
b.WriteString(". [")
|
||||
b.WriteString(humanSize(files[i].Size))
|
||||
b.WriteString("] ")
|
||||
b.WriteString(". ")
|
||||
b.WriteString(files[i].Path)
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(" (")
|
||||
b.WriteString(humanSize(files[i].Size))
|
||||
b.WriteString(")\n")
|
||||
}
|
||||
if shown < n {
|
||||
b.WriteString("… и ещё ")
|
||||
|
||||
@@ -54,7 +54,7 @@ func (r FileRole) valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// File — входной файл торрента (путь относительно content_path и размер).
|
||||
// File — входной файл торрента (путь относительно save_path и размер).
|
||||
type File struct {
|
||||
Path string
|
||||
Size int64
|
||||
@@ -118,9 +118,10 @@ type Result struct {
|
||||
Plan Plan
|
||||
PreParse PreParse
|
||||
Decision Decision
|
||||
Match *Match // подтверждённый матч в базе (nil — нет)
|
||||
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора)
|
||||
Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm)
|
||||
Match *Match // подтверждённый единичный матч (nil — нет)
|
||||
Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
|
||||
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи)
|
||||
Raw string // сырой ответ LLM последней попытки
|
||||
}
|
||||
|
||||
// LLM — нужная recognize часть провайдера.
|
||||
@@ -234,8 +235,9 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||
}
|
||||
|
||||
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
|
||||
// в плане заменяем на каноничные.
|
||||
match := r.matchMetadata(ctx, plan)
|
||||
// в плане заменяем на каноничные. Кандидаты копим для ручного выбора в
|
||||
// review, когда единичного сильного матча нет.
|
||||
match, candidates := r.matchMetadata(ctx, plan)
|
||||
if match != nil {
|
||||
plan.Title = match.Title
|
||||
if match.Year != 0 {
|
||||
@@ -247,12 +249,14 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
|
||||
r.log.Info("recognize: done",
|
||||
"type", plan.Type, "title", plan.Title, "year", plan.Year,
|
||||
"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{
|
||||
Plan: plan,
|
||||
PreParse: pre,
|
||||
Decision: dec,
|
||||
Match: match,
|
||||
Candidates: candidates,
|
||||
Attempts: attempts,
|
||||
Raw: raw,
|
||||
}, nil
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
func parsePlan(raw string, in Input) (Plan, error) {
|
||||
jsonStr, err := llm.ExtractJSONObject(raw)
|
||||
if err != nil {
|
||||
return Plan{}, fmt.Errorf("в ответе нет JSON-объекта")
|
||||
return Plan{}, fmt.Errorf("no JSON object in response")
|
||||
}
|
||||
|
||||
var p Plan
|
||||
@@ -25,7 +25,7 @@ func parsePlan(raw string, in Input) (Plan, error) {
|
||||
// Повторяем без строгого режима: лишние поля — не повод падать,
|
||||
// но если и так не разобралось — это ошибка схемы.
|
||||
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 {
|
||||
case MediaMovie, MediaSeries:
|
||||
case "":
|
||||
return fmt.Errorf("поле type пустое (ожидалось movie или series)")
|
||||
return fmt.Errorf("field type is empty (expected movie or series)")
|
||||
default:
|
||||
return fmt.Errorf("неизвестный type %q", p.Type)
|
||||
return fmt.Errorf("unknown type %q", p.Type)
|
||||
}
|
||||
if strings.TrimSpace(p.Title) == "" {
|
||||
return fmt.Errorf("поле title пустое")
|
||||
return fmt.Errorf("field title is empty")
|
||||
}
|
||||
if len(p.Files) == 0 {
|
||||
return fmt.Errorf("список files пуст")
|
||||
return fmt.Errorf("files list is empty")
|
||||
}
|
||||
|
||||
known := make(map[string]bool, len(in.Files))
|
||||
@@ -61,16 +61,16 @@ func validateSchema(p *Plan, in Input) error {
|
||||
for i := range p.Files {
|
||||
pf := &p.Files[i]
|
||||
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) == "" {
|
||||
return fmt.Errorf("файл с пустым src")
|
||||
return fmt.Errorf("file with empty 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 {
|
||||
return fmt.Errorf("серия %q без номера episode", pf.Src)
|
||||
return fmt.Errorf("episode %q has no episode number", pf.Src)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -37,14 +37,14 @@ func TestValidateSchema_Errors(t *testing.T) {
|
||||
p Plan
|
||||
want string
|
||||
}{
|
||||
{"empty type", Plan{Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "type пустое"},
|
||||
{"bad type", Plan{Type: "show", Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "неизвестный type"},
|
||||
{"empty title", Plan{Type: MediaMovie, Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "title пустое"},
|
||||
{"no files", Plan{Type: MediaMovie, Title: "x"}, "files пуст"},
|
||||
{"bad role", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: "boss"}}}, "неизвестная role"},
|
||||
{"empty src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "", Role: RoleMain}}}, "пустым src"},
|
||||
{"unknown src", Plan{Type: MediaMovie, Title: "x", Files: []PlanFile{{Src: "z.mkv", Role: RoleMain}}}, "не найден"},
|
||||
{"episode no num", Plan{Type: MediaSeries, Title: "x", Files: []PlanFile{{Src: "a.mkv", Role: RoleEpisode, Season: intp(1)}}}, "без номера episode"},
|
||||
{"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}}}, "unknown type"},
|
||||
{"empty title", Plan{Type: MediaMovie, Files: []PlanFile{{Src: "a.mkv", Role: RoleMain}}}, "title is empty"},
|
||||
{"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"}}}, "unknown role"},
|
||||
{"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}}}, "not found among torrent files"},
|
||||
{"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 {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -159,6 +159,18 @@ func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Dow
|
||||
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 переводит загрузку в новое состояние. Ключ
|
||||
// идемпотентности пересчитывается из текущего infohash: для терминального
|
||||
// состояния снимается (NULL), иначе равен infohash — так partial unique
|
||||
|
||||
@@ -228,3 +228,92 @@ func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) erro
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
st := newTestStore(t)
|
||||
dl := seedDownload(t, st)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// Package tgbot — Telegram-адаптер, парсер сообщений бота и исходящие пинги.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф5 (см. docs/specs/review-ux.md).
|
||||
package tgbot
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package worker
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTranslatePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
m map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty map returns path as is",
|
||||
path: "/srv/media/downloads",
|
||||
m: nil,
|
||||
want: "/srv/media/downloads",
|
||||
},
|
||||
{
|
||||
name: "no matching prefix returns path as is",
|
||||
path: "/other/downloads",
|
||||
m: map[string]string{"/data": "/srv/media"},
|
||||
want: "/other/downloads",
|
||||
},
|
||||
{
|
||||
name: "prefix translated on segment boundary",
|
||||
path: "/data/downloads/Show/e1.mkv",
|
||||
m: map[string]string{"/data": "/srv/media"},
|
||||
want: "/srv/media/downloads/Show/e1.mkv",
|
||||
},
|
||||
{
|
||||
name: "exact match of key",
|
||||
path: "/data",
|
||||
m: map[string]string{"/data": "/srv/media"},
|
||||
want: "/srv/media",
|
||||
},
|
||||
{
|
||||
name: "does not match partial segment",
|
||||
path: "/data2/downloads",
|
||||
m: map[string]string{"/data": "/srv/media"},
|
||||
want: "/data2/downloads",
|
||||
},
|
||||
{
|
||||
name: "longest matching prefix wins",
|
||||
path: "/data/dl/x.mkv",
|
||||
m: map[string]string{
|
||||
"/data": "/srv/A",
|
||||
"/data/dl": "/srv/B",
|
||||
},
|
||||
want: "/srv/B/x.mkv",
|
||||
},
|
||||
{
|
||||
name: "trailing slashes in keys/values normalized",
|
||||
path: "/data/downloads/x.mkv",
|
||||
m: map[string]string{"/data/": "/srv/media/"},
|
||||
want: "/srv/media/downloads/x.mkv",
|
||||
},
|
||||
{
|
||||
name: "empty path returns empty",
|
||||
path: "",
|
||||
m: map[string]string{"/data": "/srv/media"},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := translatePath(tc.path, tc.m); got != tc.want {
|
||||
t.Errorf("translatePath(%q) = %q, want %q", tc.path, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+281
-34
@@ -7,9 +7,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
@@ -19,6 +21,11 @@ import (
|
||||
const (
|
||||
ovrMediaType = "media_type"
|
||||
ovrIgnoredFiles = "ignored_files"
|
||||
ovrProvider = "provider" // выбранная база ("none" = без базы)
|
||||
ovrProviderID = "provider_id" // id в выбранной базе
|
||||
ovrTitle = "title" // запиненное каноническое название
|
||||
ovrYear = "year" // запиненный год
|
||||
ovrForceReview = "force_review" // ручная перепривязка: не авто-раскладывать
|
||||
)
|
||||
|
||||
// recognizePending распознаёт завершённые загрузки и перезапускает те, что
|
||||
@@ -75,14 +82,14 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
||||
// относительных путей файлов в абсолютные при раскладке.
|
||||
func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
return recognize.Result{}, "", err
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
@@ -103,11 +110,12 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
|
||||
in.Files[i] = recognize.File{Path: f.Name, Size: f.Size}
|
||||
}
|
||||
|
||||
savePath := translatePath(t.SavePath, w.cfg.PathMap)
|
||||
res, err := w.recognizer.Recognize(ctx, in)
|
||||
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
|
||||
@@ -158,15 +166,26 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
||||
"download_id", id, "state", d.State)
|
||||
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)
|
||||
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);
|
||||
// иначе — review. Раскладчик может быть не сконфигурирован.
|
||||
if res.Decision.Auto && w.layouter != nil {
|
||||
plan := applyOverrides(res.Plan, w.overridesOrNil(ctx, id))
|
||||
// иначе — review. Раскладчик может быть не сконфигурирован. При ручной
|
||||
// перепривязке (force_review) авто-раскладку не делаем — нужно явное
|
||||
// подтверждение человеком.
|
||||
overrides := w.overridesOrNil(ctx, id)
|
||||
forceReview := overrides[ovrForceReview] == "1"
|
||||
if res.Decision.Auto && !forceReview && w.layouter != nil {
|
||||
plan := applyOverrides(res.Plan, overrides)
|
||||
w.transition(ctx, *d, store.StateLinking, "", "")
|
||||
if err := w.linkPlan(ctx, d, plan, tag, savePath); err != nil {
|
||||
w.log.Warn("recognize: auto-apply failed, left for review",
|
||||
@@ -195,7 +214,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.layouter == nil {
|
||||
return fmt.Errorf("apply: раскладчик не сконфигурирован")
|
||||
return fmt.Errorf("apply: layouter not configured")
|
||||
}
|
||||
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
@@ -203,7 +222,7 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
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)
|
||||
@@ -212,11 +231,11 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
||||
}
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||
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, "", "")
|
||||
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 nil
|
||||
@@ -229,7 +248,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag))
|
||||
if err != nil {
|
||||
w.transition(ctx, *d, store.StateReview, "build", err.Error())
|
||||
return fmt.Errorf("построение ссылок: %w", err)
|
||||
return fmt.Errorf("build links: %w", err)
|
||||
}
|
||||
|
||||
batch := w.newID()
|
||||
@@ -249,7 +268,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
}
|
||||
if len(fl) > 0 {
|
||||
if err := w.store.CreateFileLinks(ctx, fl); err != nil {
|
||||
return fmt.Errorf("запись ссылок: %w", err)
|
||||
return fmt.Errorf("persist links: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,11 +286,70 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
return nil
|
||||
}
|
||||
|
||||
// Relink повторно привязывает откатанную (reverted) или отклонённую
|
||||
// (cancelled) задачу: возвращает её на распознавание, и поллинг-цикл
|
||||
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
|
||||
// перепривязка всегда проходит через ревью с подтверждением (force_review).
|
||||
// Источник (раздача в qBittorrent) для этого должен быть на месте.
|
||||
func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
if d.State != store.StateReverted && d.State != store.StateCancelled {
|
||||
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled)", id, d.State)
|
||||
}
|
||||
if !d.Infohash.Valid {
|
||||
return fmt.Errorf("relink: download %d has no infohash", id)
|
||||
}
|
||||
// Раздача должна ещё быть в qBittorrent — без неё распознавать нечего.
|
||||
if _, ok, terr := w.torrentByInfohash(ctx, d.Infohash.String); terr != nil {
|
||||
return fmt.Errorf("relink: %w", terr)
|
||||
} else if !ok {
|
||||
return fmt.Errorf("relink: торрент не найден в qBittorrent")
|
||||
}
|
||||
// Вернуть задачу в активную обработку можно, только если другой активной
|
||||
// задачи на этот infohash нет (partial unique index по idempotency_key).
|
||||
active, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
if active != nil {
|
||||
return fmt.Errorf("relink: для этого торрента уже есть активная задача #%d", active.ID)
|
||||
}
|
||||
// Ручная перепривязка — всегда с подтверждением, без авто-раскладки.
|
||||
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
w.log.Info("relink: re-recognizing download", "download_id", id, "from", d.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rerecognize перезапускает распознавание для задачи в review/deferred без
|
||||
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
|
||||
// цикл проведёт задачу recognizing → review заново.
|
||||
func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
d, err := w.requireReviewable(ctx, id, "rerecognize")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.log.Info("review: re-recognizing without hint", "download_id", id)
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
|
||||
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||
hint = strings.TrimSpace(hint)
|
||||
if hint == "" {
|
||||
return fmt.Errorf("refine: пустая подсказка")
|
||||
return fmt.Errorf("refine: empty hint")
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -283,6 +361,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
||||
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, "", "")
|
||||
return nil
|
||||
}
|
||||
@@ -291,7 +370,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||
// — чтобы LLM пересобрал роли файлов под новый тип.
|
||||
func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error {
|
||||
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()
|
||||
defer w.mu.Unlock()
|
||||
@@ -319,7 +398,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
|
||||
func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return fmt.Errorf("ignore: пустой путь")
|
||||
return fmt.Errorf("ignore: empty path")
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
@@ -339,6 +418,7 @@ func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
|
||||
if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil {
|
||||
return fmt.Errorf("ignore: %w", err)
|
||||
}
|
||||
w.log.Info("review: file ignored", "download_id", id, "src", src)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -352,7 +432,7 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("defer: %w", err)
|
||||
}
|
||||
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, "", "")
|
||||
return nil
|
||||
@@ -364,7 +444,7 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.layouter == nil {
|
||||
return fmt.Errorf("undo: раскладчик не сконфигурирован")
|
||||
return fmt.Errorf("undo: layouter not configured")
|
||||
}
|
||||
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
@@ -372,14 +452,14 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
}
|
||||
if batch == "" {
|
||||
return fmt.Errorf("undo: нечего откатывать")
|
||||
return fmt.Errorf("undo: nothing to revert")
|
||||
}
|
||||
rows, err := w.store.ListFileLinksByBatch(ctx, batch)
|
||||
if err != nil {
|
||||
@@ -408,11 +488,102 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
|
||||
return nil, fmt.Errorf("%s: %w", op, err)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// --- Выбор базы метаданных (пиннинг; остаёмся в 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 — всё, что нужно транспорту для отрисовки ревью.
|
||||
@@ -421,6 +592,9 @@ type ReviewData struct {
|
||||
Recognition *store.Recognition
|
||||
Plan recognize.Plan // эффективный (с применёнными правками)
|
||||
Preview []layout.Link // целевые пути (Src — относительный, для показа)
|
||||
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
|
||||
Provider string // эффективный провайдер (с учётом выбора)
|
||||
ProviderID string // эффективный id в базе
|
||||
Hints []string
|
||||
Overrides map[string]string
|
||||
}
|
||||
@@ -444,18 +618,35 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
|
||||
return nil, fmt.Errorf("review data: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
rd.Plan = plan
|
||||
// Превью строим по относительным путям с provider-тегом; ошибку
|
||||
// игнорируем — просто покажем причины без превью.
|
||||
// логируем на Debug — просто покажем причины без превью.
|
||||
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 {
|
||||
rd.Preview = links
|
||||
} else {
|
||||
w.log.Debug("review data: build preview failed (skipped)",
|
||||
"download_id", id, "err", lerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,28 +662,37 @@ func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, s
|
||||
return recognize.Plan{}, "", err
|
||||
}
|
||||
if rec == nil || !rec.Plan.Valid {
|
||||
return recognize.Plan{}, "", fmt.Errorf("нет плана распознавания")
|
||||
return recognize.Plan{}, "", fmt.Errorf("no recognition plan")
|
||||
}
|
||||
var plan recognize.Plan
|
||||
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)
|
||||
if err != nil {
|
||||
return recognize.Plan{}, "", err
|
||||
}
|
||||
tag := providerTag(rec.Provider.String, rec.ProviderID.String)
|
||||
return applyOverrides(plan, overrides), tag, nil
|
||||
prov, pid := effectiveProvider(rec, overrides)
|
||||
return applyOverrides(plan, overrides), providerTag(prov, pid), nil
|
||||
}
|
||||
|
||||
// --- Хелперы преобразования ---
|
||||
|
||||
// applyOverrides применяет ручные правки к плану: форсит тип и помечает
|
||||
// игнорируемые файлы ролью ignore (их раскладка пропустит).
|
||||
// applyOverrides применяет ручные правки к плану: форсит тип, каноническое
|
||||
// имя/год (из выбранного кандидата базы) и помечает игнорируемые файлы ролью
|
||||
// ignore (их раскладка пропустит).
|
||||
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
|
||||
if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) {
|
||||
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])
|
||||
if len(ignored) > 0 {
|
||||
for i := range plan.Files {
|
||||
@@ -504,6 +704,48 @@ func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.
|
||||
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-…"
|
||||
// / "tvdbid-…". Пустой id (нет матча) → пустой тег.
|
||||
func providerTag(provider, id string) string {
|
||||
@@ -515,6 +757,8 @@ func providerTag(provider, id string) string {
|
||||
return "tmdbid-" + id
|
||||
case "tvdb":
|
||||
return "tvdbid-" + id
|
||||
case "imdb":
|
||||
return "imdbid-" + id
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -563,9 +807,12 @@ func mapRole(r recognize.FileRole) (layout.Role, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// torrentByInfohash ищет торрент категории по infohash (v1/v2/hash).
|
||||
// torrentByInfohash ищет торрент по infohash (v1/v2/hash). Листаем ВСЕ
|
||||
// торренты (а не только свою категорию): раздача могла быть усыновлена по
|
||||
// тегу и иметь чужую/пустую категорию — фильтр по категории её бы потерял
|
||||
// (как и в Poll, см. там же).
|
||||
func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) {
|
||||
torrents, err := w.qbt.Torrents(ctx, w.cfg.Category)
|
||||
torrents, err := w.qbt.Torrents(ctx, "")
|
||||
if err != nil {
|
||||
return qbt.Torrent{}, false, err
|
||||
}
|
||||
|
||||
@@ -2,19 +2,219 @@ package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/recognize"
|
||||
"git.vakhrushev.me/av/jellybit/internal/store"
|
||||
)
|
||||
|
||||
// recordingNotifier ловит события пинга (Notify асинхронен — через канал).
|
||||
type notifyEvent struct {
|
||||
id int64
|
||||
ev NotifyEvent
|
||||
}
|
||||
type recordingNotifier struct{ ch chan notifyEvent }
|
||||
|
||||
func (n *recordingNotifier) Notify(_ context.Context, id int64, ev NotifyEvent) {
|
||||
n.ch <- notifyEvent{id, ev}
|
||||
}
|
||||
|
||||
func waitNotify(t *testing.T, n *recordingNotifier) notifyEvent {
|
||||
t.Helper()
|
||||
select {
|
||||
case e := <-n.ch:
|
||||
return e
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("пинг не пришёл")
|
||||
return notifyEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifier_FiresOnReview(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
qb := &fakeQbt{
|
||||
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
|
||||
files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}},
|
||||
}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
|
||||
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
||||
w.SetNotifier(n)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
|
||||
e := waitNotify(t, n)
|
||||
if e.id != 1 || e.ev != EventReview {
|
||||
t.Errorf("event = %+v, want {1 review}", e)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifier_FiresOnDone(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
||||
f.w.SetNotifier(n)
|
||||
|
||||
if err := f.w.Apply(context.Background(), 1); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
e := waitNotify(t, n)
|
||||
if e.id != 1 || e.ev != EventDone {
|
||||
t.Errorf("event = %+v, want {1 done}", e)
|
||||
}
|
||||
}
|
||||
|
||||
// recordingScanner ловит вызовы пересканирования Jellyfin (RefreshLibraries
|
||||
// асинхронен — через канал).
|
||||
type recordingScanner struct{ ch chan struct{} }
|
||||
|
||||
func (s *recordingScanner) RefreshLibraries(_ context.Context) error {
|
||||
s.ch <- struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestScanner_FiresOnDone(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
s := &recordingScanner{ch: make(chan struct{}, 4)}
|
||||
f.w.SetScanner(s)
|
||||
|
||||
if err := f.w.Apply(context.Background(), 1); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-s.ch:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("пересканирование Jellyfin не запустилось")
|
||||
}
|
||||
}
|
||||
|
||||
func revertedDownload(id int64) *store.Download {
|
||||
d := completedDownload(id)
|
||||
d.State = store.StateReverted
|
||||
return d
|
||||
}
|
||||
|
||||
func TestRelink_RevertedToRecognizing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(revertedDownload(1))
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
|
||||
|
||||
if err := w.Relink(context.Background(), 1); err != nil {
|
||||
t.Fatalf("Relink: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateRecognizing {
|
||||
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
|
||||
}
|
||||
if st.overrides[1][ovrForceReview] != "1" {
|
||||
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelink_CancelledToRecognizing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := revertedDownload(1)
|
||||
d.State = store.StateCancelled
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
|
||||
|
||||
if err := w.Relink(context.Background(), 1); err != nil {
|
||||
t.Fatalf("Relink: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateRecognizing {
|
||||
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
|
||||
}
|
||||
if st.overrides[1][ovrForceReview] != "1" {
|
||||
t.Errorf("force_review override = %q, want 1", st.overrides[1][ovrForceReview])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelink_RejectsActiveState(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1)) // не reverted/cancelled
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Relink(context.Background(), 1); err == nil {
|
||||
t.Fatal("ожидали ошибку для не-reverted/cancelled задачи, получили nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerecognize_ReviewToRecognizing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Rerecognize(context.Background(), 1); err != nil {
|
||||
t.Fatalf("Rerecognize: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateRecognizing {
|
||||
t.Fatalf("state = %q, want recognizing", st.downloads[1].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRerecognize_RejectsNonReview(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1)) // completed, не review/deferred
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Rerecognize(context.Background(), 1); err == nil {
|
||||
t.Fatal("ожидали ошибку для не-review задачи, получили nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelink_TorrentMissing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(revertedDownload(1))
|
||||
qb := &fakeQbt{torrents: nil} // раздачи в qBittorrent нет
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Relink(context.Background(), 1); err == nil {
|
||||
t.Fatal("ожидали ошибку при отсутствии торрента, получили nil")
|
||||
}
|
||||
if st.downloads[1].State != store.StateReverted {
|
||||
t.Errorf("state = %q, want reverted (без изменений)", st.downloads[1].State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRelink_ForceReviewSkipsAuto проверяет, что после перепривязки даже
|
||||
// уверенный матч не уходит в авто-раскладку, а ждёт подтверждения в review.
|
||||
func TestRelink_ForceReviewSkipsAuto(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
// Готовим состояние «как после Relink»: reverted, force_review выставлен.
|
||||
f.st.downloads[1].State = store.StateReverted
|
||||
_ = f.st.SetOverride(context.Background(), 1, ovrForceReview, "1")
|
||||
|
||||
auto := seriesResult()
|
||||
auto.Decision.Auto = true
|
||||
auto.Match = &recognize.Match{Provider: "tvdb", ProviderID: "42"}
|
||||
f.w.recognizer = &fakeRecognizer{result: auto}
|
||||
|
||||
if err := f.w.Relink(context.Background(), 1); err != nil {
|
||||
t.Fatalf("Relink: %v", err)
|
||||
}
|
||||
f.w.recognizeOne(context.Background(), 1)
|
||||
|
||||
if f.st.downloads[1].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review (авто-раскладка не должна сработать)", f.st.downloads[1].State)
|
||||
}
|
||||
if len(f.st.links) != 0 {
|
||||
t.Errorf("file_links = %d, want 0 (ничего не линковали)", len(f.st.links))
|
||||
}
|
||||
}
|
||||
|
||||
// memStore — полноценный in-memory store для тестов Ф3.
|
||||
type memStore struct {
|
||||
downloads map[int64]*store.Download
|
||||
@@ -22,6 +222,7 @@ type memStore struct {
|
||||
hints map[int64][]string
|
||||
overrides map[int64]map[string]string
|
||||
links []store.FileLink
|
||||
candidates []store.MetadataCandidate
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
@@ -46,6 +247,33 @@ func (m *memStore) ListDownloadsByState(_ context.Context, states ...store.State
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
|
||||
for _, d := range m.downloads {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *memStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
|
||||
for _, d := range m.downloads {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
|
||||
cp := *d
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
||||
id := int64(len(m.downloads) + 1)
|
||||
cp := *d
|
||||
cp.ID = id
|
||||
m.downloads[id] = &cp
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
||||
d, ok := m.downloads[id]
|
||||
if !ok {
|
||||
@@ -143,6 +371,40 @@ func (m *memStore) DeleteFileLinksByBatch(_ context.Context, batch string) error
|
||||
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) {
|
||||
b, err := json.Marshal(v)
|
||||
return string(b), err
|
||||
@@ -234,6 +496,42 @@ func TestRecognizeOne_CompletedToReview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecognizeOne_FindsTagAdoptedTorrent — регрессия: раздача, усыновлённая
|
||||
// по тегу, имеет чужую (или пустую) категорию. Поиск по infohash при
|
||||
// распознавании обязан её найти; раньше фильтр по w.cfg.Category её терял и
|
||||
// распознавание падало с «torrent not found in qBittorrent».
|
||||
func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
qb := &fakeQbt{
|
||||
torrents: []qbt.Torrent{{
|
||||
Hash: ihTest, Name: "ThePitt", SavePath: "/d",
|
||||
Category: "movies", Tags: "jellybit", // тег наш, категория чужая
|
||||
}},
|
||||
files: []qbt.File{{Name: "ThePitt/e1.mkv", Size: 100}, {Name: "ThePitt/e2.mkv", Size: 100}},
|
||||
}
|
||||
rec := &fakeRecognizer{result: seriesResult()}
|
||||
w := testWorkerWith(st, qb, rec, nil)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
|
||||
if st.downloads[1].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review", st.downloads[1].State)
|
||||
}
|
||||
// Recognizer вернул бы Title="Show" только если торрент найден по infohash;
|
||||
// при потере (фильтр по категории) был бы пустой план с причиной «not found».
|
||||
cur, _ := st.GetCurrentRecognition(context.Background(), 1)
|
||||
if cur == nil || cur.Title.String != "Show" {
|
||||
t.Fatalf("recognizer did not run on found torrent (title=%q): torrent must be found by infohash despite foreign category",
|
||||
func() string {
|
||||
if cur == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return cur.Title.String
|
||||
}())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
@@ -381,7 +679,7 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -543,7 +841,7 @@ func TestRecognizeOne_AutoApplies(t *testing.T) {
|
||||
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
_ = 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.put(completedDownload(1))
|
||||
@@ -591,6 +889,7 @@ func TestProviderTag(t *testing.T) {
|
||||
cases := []struct{ provider, id, want string }{
|
||||
{"tmdb", "603", "tmdbid-603"},
|
||||
{"tvdb", "123", "tvdbid-123"},
|
||||
{"imdb", "tt2802850", "imdbid-tt2802850"},
|
||||
{"none", "", ""},
|
||||
{"tmdb", "", ""},
|
||||
{"weird", "1", ""},
|
||||
@@ -602,6 +901,143 @@ func TestProviderTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// reviewWithCandidate готовит memStore: задача в review, одна попытка
|
||||
// распознавания с одним кандидатом базы.
|
||||
func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore) {
|
||||
t.Helper()
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaSeries, Title: "Догадка", Year: 2000})
|
||||
st.recs = append(st.recs, &store.Recognition{
|
||||
ID: 1, DownloadID: 1, IsCurrent: true, Plan: store.NullString(string(planJSON)),
|
||||
Provider: store.NullString("none"),
|
||||
})
|
||||
cand.RecognitionID = 1
|
||||
_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
return w, st
|
||||
}
|
||||
|
||||
func TestRecognizeOne_PersistsCandidates(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
qb := &fakeQbt{
|
||||
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
|
||||
files: []qbt.File{{Name: "e1.mkv", Size: 1}},
|
||||
}
|
||||
res := seriesResult()
|
||||
res.Candidates = []metadata.Candidate{
|
||||
{Provider: "tvmaze", ID: "1", Title: "Show A", Year: 2006, TagProvider: "tvdb", TagID: "269613"},
|
||||
{Provider: "tvmaze", ID: "2", Title: "Show B", Year: 2007},
|
||||
}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{result: res}, nil)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
|
||||
if len(st.candidates) != 2 {
|
||||
t.Fatalf("candidates = %d, want 2", len(st.candidates))
|
||||
}
|
||||
// Тег-предпочтительный provider/id сохранён (TVMaze → tvdb).
|
||||
if st.candidates[0].Provider != "tvdb" || st.candidates[0].ProviderID != "269613" {
|
||||
t.Errorf("candidate[0] = %+v", st.candidates[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseCandidate_PinsOverrides(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "269613",
|
||||
Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true},
|
||||
})
|
||||
candID := st.candidates[0].ID
|
||||
|
||||
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil {
|
||||
t.Fatalf("ChooseCandidate: %v", err)
|
||||
}
|
||||
ov := st.overrides[1]
|
||||
if ov[ovrProvider] != "tvdb" || ov[ovrProviderID] != "269613" ||
|
||||
ov[ovrTitle] != "Fargo" || ov[ovrYear] != "2014" {
|
||||
t.Errorf("overrides = %v", ov)
|
||||
}
|
||||
if !st.candidates[0].Chosen {
|
||||
t.Error("кандидат не помечен выбранным")
|
||||
}
|
||||
// Эффективный план берёт каноническое имя/год и тег [tvdbid-...].
|
||||
plan, tag, err := w.effectivePlan(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("effectivePlan: %v", err)
|
||||
}
|
||||
if plan.Title != "Fargo" || plan.Year != 2014 {
|
||||
t.Errorf("plan = %q (%d)", plan.Title, plan.Year)
|
||||
}
|
||||
if tag != "tvdbid-269613" {
|
||||
t.Errorf("tag = %q", tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChooseCandidate_RejectsForeign(t *testing.T) {
|
||||
w, _ := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
|
||||
if err := w.ChooseCandidate(context.Background(), 1, 999); err == nil {
|
||||
t.Error("чужой кандидат должен отклоняться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetProviderID(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
|
||||
if err := w.SetProviderID(context.Background(), 1, "TMDB", " 603 "); err != nil {
|
||||
t.Fatalf("SetProviderID: %v", err)
|
||||
}
|
||||
if st.overrides[1][ovrProvider] != "tmdb" || st.overrides[1][ovrProviderID] != "603" {
|
||||
t.Errorf("overrides = %v", st.overrides[1])
|
||||
}
|
||||
if err := w.SetProviderID(context.Background(), 1, "kinopoisk", "1"); err == nil {
|
||||
t.Error("недопустимый провайдер должен отклоняться")
|
||||
}
|
||||
if err := w.SetProviderID(context.Background(), 1, "tmdb", ""); err == nil {
|
||||
t.Error("пустой id должен отклоняться")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearProvider(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "1"})
|
||||
_ = st.SetOverride(context.Background(), 1, ovrProvider, "tvdb")
|
||||
if err := w.ClearProvider(context.Background(), 1); err != nil {
|
||||
t.Fatalf("ClearProvider: %v", err)
|
||||
}
|
||||
if st.overrides[1][ovrProvider] != "none" {
|
||||
t.Errorf("provider override = %q, want none", st.overrides[1][ovrProvider])
|
||||
}
|
||||
// «Без базы» → пустой тег.
|
||||
_, tag, _ := w.effectivePlan(context.Background(), 1)
|
||||
if tag != "" {
|
||||
t.Errorf("tag = %q, want empty", tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewData_IncludesCandidates(t *testing.T) {
|
||||
w, st := reviewWithCandidate(t, store.MetadataCandidate{
|
||||
Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
|
||||
})
|
||||
candID := st.candidates[0].ID
|
||||
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rd, err := w.ReviewData(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewData: %v", err)
|
||||
}
|
||||
if len(rd.Candidates) != 1 {
|
||||
t.Fatalf("candidates = %d", len(rd.Candidates))
|
||||
}
|
||||
if rd.Provider != "tvdb" || rd.ProviderID != "269613" {
|
||||
t.Errorf("eff provider = %s/%s", rd.Provider, rd.ProviderID)
|
||||
}
|
||||
if rd.Plan.Title != "Fargo" {
|
||||
t.Errorf("plan title = %q", rd.Plan.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToLayoutPlan(t *testing.T) {
|
||||
s, e := 1, 3
|
||||
plan := recognize.Plan{
|
||||
@@ -625,3 +1061,41 @@ func TestToLayoutPlan(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,11 @@ type Store interface {
|
||||
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
||||
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
||||
|
||||
// Discovery (усыновление раздач по категории/тегу).
|
||||
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
|
||||
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
|
||||
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
|
||||
|
||||
// Ф3: распознавание, ревью, раскладка.
|
||||
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (int64, error)
|
||||
GetCurrentRecognition(ctx context.Context, downloadID int64) (*store.Recognition, error)
|
||||
@@ -42,6 +47,12 @@ type Store interface {
|
||||
LatestBatchID(ctx context.Context, downloadID int64) (string, error)
|
||||
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, 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.
|
||||
@@ -63,10 +74,32 @@ type Layouter interface {
|
||||
Undo(ctx context.Context, links []layout.Link) (int, error)
|
||||
}
|
||||
|
||||
// NotifyEvent — повод позвать пользователя.
|
||||
type NotifyEvent string
|
||||
|
||||
const (
|
||||
EventReview NotifyEvent = "review" // задача ждёт подтверждения
|
||||
EventDone NotifyEvent = "done" // раскладка завершена
|
||||
)
|
||||
|
||||
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, downloadID int64, event NotifyEvent)
|
||||
}
|
||||
|
||||
// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается
|
||||
// неблокирующе после успешной раскладки, чтобы новые файлы быстрее появились
|
||||
// в проигрывателе.
|
||||
type Scanner interface {
|
||||
RefreshLibraries(ctx context.Context) error
|
||||
}
|
||||
|
||||
// Config — параметры воркера.
|
||||
type Config struct {
|
||||
Category string
|
||||
Tag string // метка для усыновления существующих раздач (discovery)
|
||||
SavePath string
|
||||
PathMap map[string]string // трансляция save_path qBit → хост-путь (обычно пусто)
|
||||
PollInterval time.Duration
|
||||
StuckAfter time.Duration // stalledDL дольше → stuck
|
||||
MagnetTimeout time.Duration // metaDL дольше → failed
|
||||
@@ -84,8 +117,16 @@ type Worker struct {
|
||||
mu sync.Mutex // сериализует переходы (поллинг + команды)
|
||||
now func() time.Time // подменяется в тестах
|
||||
newID func() string // генератор apply_batch_id (подменяется в тестах)
|
||||
notifier Notifier // опц. исходящие пинги
|
||||
scanner Scanner // опц. пересканирование Jellyfin
|
||||
}
|
||||
|
||||
// SetNotifier подключает исходящие пинги (до запуска Run).
|
||||
func (w *Worker) SetNotifier(n Notifier) { w.notifier = n }
|
||||
|
||||
// SetScanner подключает пересканирование Jellyfin (до запуска Run).
|
||||
func (w *Worker) SetScanner(s Scanner) { w.scanner = s }
|
||||
|
||||
// New собирает воркер. recognizer/layouter могут быть nil (Ф1 без Ф3-ступеней
|
||||
// распознавания и раскладки) — тогда completed-задачи не двигаются дальше.
|
||||
func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log *slog.Logger) *Worker {
|
||||
@@ -135,8 +176,10 @@ func (w *Worker) pollOnce(ctx context.Context) {
|
||||
}
|
||||
|
||||
// Poll сверяет активные задачи с состоянием qBittorrent и двигает их.
|
||||
// Листаем все торренты (а не только свою категорию), чтобы reconcile нашёл и
|
||||
// усыновлённые по тегу раздачи, а discovery — увидел новые.
|
||||
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 {
|
||||
return fmt.Errorf("poll: list torrents: %w", err)
|
||||
}
|
||||
@@ -152,6 +195,9 @@ func (w *Worker) Poll(ctx context.Context) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
// Усыновляем новые раздачи с нашей категорией/тегом до reconcile.
|
||||
w.discover(ctx, torrents)
|
||||
|
||||
active, err := w.store.ListDownloadsByState(ctx, store.StateDownloading)
|
||||
if err != nil {
|
||||
return fmt.Errorf("poll: list active: %w", err)
|
||||
@@ -215,6 +261,29 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
||||
}
|
||||
w.log.Info("state transition",
|
||||
"download_id", d.ID, "from", d.State, "to", state, "code", code)
|
||||
|
||||
// Пинги — неблокирующе и в отдельном контексте: вызов уходит в сеть, а
|
||||
// мы под w.mu (Notify читает состояние уже после освобождения замка).
|
||||
if w.notifier != nil {
|
||||
switch state {
|
||||
case store.StateReview:
|
||||
go w.notifier.Notify(context.Background(), d.ID, EventReview)
|
||||
case store.StateDone:
|
||||
go w.notifier.Notify(context.Background(), d.ID, EventDone)
|
||||
}
|
||||
}
|
||||
|
||||
// Раскладка завершена — просим Jellyfin пересканировать библиотеку, чтобы
|
||||
// новые файлы быстрее появились в проигрывателе. Тоже неблокирующе и вне
|
||||
// w.mu; недоступность Jellyfin не влияет на состояние задачи.
|
||||
if w.scanner != nil && state == store.StateDone {
|
||||
id := d.ID
|
||||
go func() {
|
||||
if err := w.scanner.RefreshLibraries(context.Background()); err != nil {
|
||||
w.log.Warn("jellyfin: library refresh failed", "download_id", id, "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
|
||||
|
||||
@@ -51,6 +51,33 @@ func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, e
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
|
||||
for _, d := range f.downloads {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
|
||||
for _, d := range f.downloads {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
|
||||
cp := *d
|
||||
return &cp, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
||||
id := int64(len(f.downloads) + 1)
|
||||
cp := *d
|
||||
cp.ID = id
|
||||
f.downloads[id] = &cp
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
@@ -83,6 +110,16 @@ func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.F
|
||||
return nil, 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 {
|
||||
torrents []qbt.Torrent
|
||||
@@ -90,9 +127,22 @@ type fakeQbt struct {
|
||||
files []qbt.File
|
||||
}
|
||||
|
||||
func (f *fakeQbt) Torrents(_ context.Context, _ string) ([]qbt.Torrent, error) {
|
||||
// Torrents имитирует /torrents/info: пустая категория — все торренты, иначе
|
||||
// только торренты этой категории (как реальный qBittorrent). Это важно для
|
||||
// регрессии: раздача, усыновлённая по тегу, имеет чужую категорию и не должна
|
||||
// теряться при поиске по infohash.
|
||||
func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, error) {
|
||||
if category == "" {
|
||||
return f.torrents, nil
|
||||
}
|
||||
var out []qbt.Torrent
|
||||
for _, t := range f.torrents {
|
||||
if t.Category == category {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
||||
f.added = append(f.added, ar)
|
||||
|
||||
@@ -69,6 +69,11 @@
|
||||
<button type="submit">Откатить</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .Relinkable}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/relink">
|
||||
<button type="submit">Привязать заново</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if not .Terminal}}
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/cancel">
|
||||
<button type="submit">Отклонить</button>
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
|
||||
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{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>
|
||||
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
|
||||
Переключить тип:
|
||||
@@ -67,6 +67,49 @@
|
||||
</form>
|
||||
</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>
|
||||
<strong>Файлы → роль</strong>
|
||||
<table>
|
||||
@@ -107,6 +150,10 @@
|
||||
<p><textarea name="hint" rows="2" placeholder="подсказка для распознавания, напр. «это второй сезон, рус+англ дорожки»"></textarea></p>
|
||||
<button type="submit">🔁 Уточнить</button>
|
||||
</form>
|
||||
<form method="post" action="/ui/downloads/{{.ID}}/rerecognize" style="margin-top:.4rem">
|
||||
<button type="submit">🔄 Распознать заново</button>
|
||||
<small>(без новой подсказки — по уже накопленному контексту)</small>
|
||||
</form>
|
||||
{{if .Hints}}
|
||||
<p><small>Подсказки: {{range $i, $h := .Hints}}{{if $i}} · {{end}}«{{$h}}»{{end}}</small></p>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user