From 7f7f5f69d48614bc021ab33b5a8dd3196848c58d Mon Sep 17 00:00:00 2001
From: Anton Vakhrushev
Date: Sun, 14 Jun 2026 16:43:50 +0300
Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB=20?=
=?UTF-8?q?=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=B8=D0=B7=20=D0=BA=D0=B0?=
=?UTF-8?q?=D0=BD=D0=B4=D0=B8=D0=B4=D0=B0=D1=82=D0=BE=D0=B2,=20=D0=B5?=
=?UTF-8?q?=D1=81=D0=BB=D0=B8=20LLM=20=D0=BD=D0=B5=20=D1=83=D0=B2=D0=B5?=
=?UTF-8?q?=D1=80=D0=B5=D0=BD=D0=B0=20=D0=B2=20=D1=80=D0=B0=D1=81=D0=BA?=
=?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=BA=D0=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
config.example.toml | 2 +-
docs/specs/architecture.md | 9 +-
internal/config/config.go | 5 +-
internal/httpapi/httpapi.go | 3 +
internal/httpapi/httpapi_test.go | 87 +++++++++++--
internal/httpapi/review.go | 59 ++++++++-
internal/recognize/metadata.go | 92 +++++++++-----
internal/recognize/metadata_test.go | 60 +++++++--
internal/recognize/recognize.go | 34 ++---
internal/store/recognition.go | 89 ++++++++++++++
internal/store/recognition_test.go | 61 +++++++++
internal/worker/review.go | 173 ++++++++++++++++++++++++--
internal/worker/review_test.go | 184 +++++++++++++++++++++++++++-
internal/worker/worker.go | 6 +
internal/worker/worker_test.go | 10 ++
web/templates/review.html | 45 ++++++-
16 files changed, 831 insertions(+), 88 deletions(-)
diff --git a/config.example.toml b/config.example.toml
index 5b2bf56..1090f4a 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -61,7 +61,7 @@ web_base_url = "" # напр. "http://umbar:8080" — для
[http]
listen = ":8080"
-trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
+trusted_subnets = [] # ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN); зарезервировано
[log]
level = "info"
diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md
index 48cb573..1361a73 100644
--- a/docs/specs/architecture.md
+++ b/docs/specs/architecture.md
@@ -84,9 +84,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/готовности.
@@ -189,7 +190,7 @@ allowed_user_ids = [] # пусто = запрет всем (fail-closed)
[http]
listen = ":8080"
-trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений
+trusted_subnets = [] # зарезервировано, ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN)
[log]
level = "info"
diff --git a/internal/config/config.go b/internal/config/config.go
index 74c5e16..4558242 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -95,7 +95,10 @@ type Telegram struct {
// HTTP — параметры веб-сервера.
type HTTP struct {
- Listen string `toml:"listen"`
+ Listen string `toml:"listen"`
+ // TrustedSubnets — allowlist подсетей. ПОКА НЕ ПРИМЕНЯЕТСЯ: деплой только
+ // в локальную сеть без доступа из интернета, поэтому middleware отложено
+ // (см. architecture.md). Поле сохранено под будущую реализацию.
TrustedSubnets []string `toml:"trusted_subnets"`
}
diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go
index 045007d..659648b 100644
--- a/internal/httpapi/httpapi.go
+++ b/internal/httpapi/httpapi.go
@@ -87,6 +87,9 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Post("/ui/downloads/{id}/refine", s.handleRefine)
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)
diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go
index 0d531f2..a5ba495 100644
--- a/internal/httpapi/httpapi_test.go
+++ b/internal/httpapi/httpapi_test.go
@@ -2,6 +2,7 @@ package httpapi_test
import (
"context"
+ "database/sql"
"encoding/json"
"io"
"log/slog"
@@ -182,14 +183,17 @@ func (e ingestErr) Error() string { return string(e) }
// --- Ревью ---
type fakeReviewer struct {
- data *worker.ReviewData
- applyErr error
- refined map[int64]string
- typed map[int64]string
- ignored map[int64]string
- applied []int64
- deferred []int64
- undone []int64
+ data *worker.ReviewData
+ applyErr error
+ 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
+ cleared []int64
}
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
@@ -231,6 +235,24 @@ func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
f.undone = append(f.undone, id)
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 +270,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 +301,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{},
diff --git a/internal/httpapi/review.go b/internal/httpapi/review.go
index f7a6728..1645c3f 100644
--- a/internal/httpapi/review.go
+++ b/internal/httpapi/review.go
@@ -20,6 +20,9 @@ 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
+ ChooseCandidate(ctx context.Context, id, candidateID int64) error
+ SetProviderID(ctx context.Context, id int64, provider, providerID string) error
+ ClearProvider(ctx context.Context, id int64) error
}
// --- Представление страницы ревью ---
@@ -44,6 +47,8 @@ type reviewView struct {
Files []reviewFileView
Preview []string
HasPlan bool
+ NoBase bool // выбрано «без базы»
+ Candidates []candidateView
}
type reviewFileView struct {
@@ -54,6 +59,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 +101,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 +121,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)
@@ -151,6 +178,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 {
diff --git a/internal/recognize/metadata.go b/internal/recognize/metadata.go
index cd51c73..9ddd812 100644
--- a/internal/recognize/metadata.go
+++ b/internal/recognize/metadata.go
@@ -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,44 +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 = r.buildMatch(ctx, p, strong[0], mt)
+ }
+ return match, candidates
+}
- // Число серий тянем по нативному id провайдера.
- var counts map[int]int
- if mt == metadata.Series {
- if got, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil {
- counts = got
- } else {
- r.log.Warn("recognize: episode counts failed",
- "provider", p.Name(), "id", c.ID, "err", cerr)
- }
- }
-
- // Провенанс и тег папки — по внешнему id, если провайдер его дал
- // (TVMaze отдаёт TVDB/IMDb-id); иначе по самому провайдеру.
- prov, pid := c.Provider, c.ID
- if c.TagProvider != "" {
- prov, pid = c.TagProvider, c.TagID
- }
- return &Match{
- Provider: prov,
- ProviderID: pid,
- Title: c.Title,
- Year: c.Year,
- SeasonEpisodeCounts: counts,
+// buildMatch тянет число серий (по нативному id) и собирает Match с
+// тег-предпочтительным провенансом.
+func (r *Recognizer) buildMatch(ctx context.Context, p metadata.Provider, c metadata.Candidate, mt metadata.MediaType) *Match {
+ var counts map[int]int
+ if mt == metadata.Series {
+ if got, err := p.SeasonEpisodeCounts(ctx, c.ID); err == nil {
+ counts = got
+ } else {
+ r.log.Warn("recognize: episode counts failed", "provider", p.Name(), "id", c.ID, "err", err)
}
}
- return nil
+ prov, pid := CandidateTag(c)
+ return &Match{
+ Provider: prov,
+ ProviderID: pid,
+ Title: c.Title,
+ Year: c.Year,
+ SeasonEpisodeCounts: counts,
+ }
+}
+
+// CandidateTag — провайдер и id для тега папки Jellyfin: внешний (из
+// TagProvider/TagID, напр. TVMaze → tvdb/imdb), если есть, иначе сам провайдер
+// поиска. Используется и в матче, и при сохранении кандидатов.
+func CandidateTag(c metadata.Candidate) (provider, id string) {
+ if c.TagProvider != "" {
+ return c.TagProvider, c.TagID
+ }
+ return c.Provider, c.ID
}
// strongMatches оставляет кандидатов, чьё название совпадает с одним из
diff --git a/internal/recognize/metadata_test.go b/internal/recognize/metadata_test.go
index 7675b49..84500c5 100644
--- a/internal/recognize/metadata_test.go
+++ b/internal/recognize/metadata_test.go
@@ -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,7 +125,7 @@ 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)
@@ -98,7 +142,7 @@ func TestMatchMetadata_TagFromExternal(t *testing.T) {
counts: map[int]int{1: 10},
}
r := recognizerWith(p)
- m := r.matchMetadata(context.Background(),
+ m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
if m == nil {
t.Fatal("expected match")
@@ -118,7 +162,7 @@ func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
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)
@@ -128,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)
}
@@ -136,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)
}
}
diff --git a/internal/recognize/recognize.go b/internal/recognize/recognize.go
index 8e4dbca..d80f26d 100644
--- a/internal/recognize/recognize.go
+++ b/internal/recognize/recognize.go
@@ -115,12 +115,13 @@ type Match struct {
// Result — итог распознавания.
type Result struct {
- Plan Plan
- PreParse PreParse
- Decision Decision
- Match *Match // подтверждённый матч в базе (nil — нет)
- Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора)
- Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm)
+ Plan Plan
+ PreParse PreParse
+ Decision Decision
+ 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,13 +249,15 @@ 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,
- Attempts: attempts,
- Raw: raw,
+ Plan: plan,
+ PreParse: pre,
+ Decision: dec,
+ Match: match,
+ Candidates: candidates,
+ Attempts: attempts,
+ Raw: raw,
}, nil
}
diff --git a/internal/store/recognition.go b/internal/store/recognition.go
index 39b9fa6..4a2ac12 100644
--- a/internal/store/recognition.go
+++ b/internal/store/recognition.go
@@ -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
+}
diff --git a/internal/store/recognition_test.go b/internal/store/recognition_test.go
index 491bfe3..eafe583 100644
--- a/internal/store/recognition_test.go
+++ b/internal/store/recognition_test.go
@@ -159,6 +159,67 @@ 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 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)
diff --git a/internal/worker/review.go b/internal/worker/review.go
index a428058..08bb7e9 100644
--- a/internal/worker/review.go
+++ b/internal/worker/review.go
@@ -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,10 @@ import (
const (
ovrMediaType = "media_type"
ovrIgnoredFiles = "ignored_files"
+ ovrProvider = "provider" // выбранная база ("none" = без базы)
+ ovrProviderID = "provider_id" // id в выбранной базе
+ ovrTitle = "title" // запиненное каноническое название
+ ovrYear = "year" // запиненный год
)
// recognizePending распознаёт завершённые загрузки и перезапускает те, что
@@ -158,10 +164,17 @@ 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. Раскладчик может быть не сконфигурирован.
@@ -413,14 +426,105 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
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: кандидат %d не относится к текущему распознаванию", 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: недопустимый провайдер %q (tmdb/tvdb/imdb)", provider)
+ }
+ if providerID == "" {
+ return fmt.Errorf("set provider: пустой 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)
+ }
+ 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)
+ }
+ return nil
+}
+
// --- Данные для экрана ревью ---
// ReviewData — всё, что нужно транспорту для отрисовки ревью.
type ReviewData struct {
Download store.Download
Recognition *store.Recognition
- Plan recognize.Plan // эффективный (с применёнными правками)
- Preview []layout.Link // целевые пути (Src — относительный, для показа)
+ Plan recognize.Plan // эффективный (с применёнными правками)
+ Preview []layout.Link // целевые пути (Src — относительный, для показа)
+ Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
+ Provider string // эффективный провайдер (с учётом выбора)
+ ProviderID string // эффективный id в базе
Hints []string
Overrides map[string]string
}
@@ -444,7 +548,16 @@ 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
+ }
+ }
if rec != nil && rec.Plan.Valid {
var plan recognize.Plan
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err == nil {
@@ -453,7 +566,7 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
// Превью строим по относительным путям с provider-тегом; ошибку
// игнорируем — просто покажем причины без превью.
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
}
@@ -481,18 +594,27 @@ func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, s
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 +626,39 @@ 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 строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
// / "tvdbid-…". Пустой id (нет матча) → пустой тег.
func providerTag(provider, id string) string {
diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go
index 599e87a..7dec1d0 100644
--- a/internal/worker/review_test.go
+++ b/internal/worker/review_test.go
@@ -2,6 +2,7 @@ package worker
import (
"context"
+ "database/sql"
"encoding/json"
"io"
"log/slog"
@@ -11,6 +12,7 @@ import (
"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"
@@ -73,11 +75,12 @@ func TestNotifier_FiresOnDone(t *testing.T) {
// memStore — полноценный in-memory store для тестов Ф3.
type memStore struct {
- downloads map[int64]*store.Download
- recs []*store.Recognition
- hints map[int64][]string
- overrides map[int64]map[string]string
- links []store.FileLink
+ downloads map[int64]*store.Download
+ recs []*store.Recognition
+ hints map[int64][]string
+ overrides map[int64]map[string]string
+ links []store.FileLink
+ candidates []store.MetadataCandidate
}
func newMemStore() *memStore {
@@ -199,6 +202,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
@@ -659,6 +696,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{
diff --git a/internal/worker/worker.go b/internal/worker/worker.go
index 369242b..11f8921 100644
--- a/internal/worker/worker.go
+++ b/internal/worker/worker.go
@@ -42,6 +42,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.
diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go
index d7633cf..6119649 100644
--- a/internal/worker/worker_test.go
+++ b/internal/worker/worker_test.go
@@ -83,6 +83,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
diff --git a/web/templates/review.html b/web/templates/review.html
index 8eb8de4..09fff53 100644
--- a/web/templates/review.html
+++ b/web/templates/review.html
@@ -57,7 +57,7 @@
Тип: {{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}
· Название: {{.Title}}{{if .OriginalTitle}} ({{.OriginalTitle}}){{end}}
{{if .Year}}· Год: {{.Year}}{{end}}
- {{if .Provider}}· База: {{.Provider}} {{.ProviderID}}{{end}}
+ {{if .Provider}}· База: {{.Provider}} {{.ProviderID}}{{else if .NoBase}}· База: без базы{{end}}
+
+ База метаданных
+ {{if .Provider}}Выбрано: {{.Provider}} {{.ProviderID}}
+ {{else if .NoBase}}Выбрано: без базы (тег папки не ставится)
+ {{else}}Матч не подтверждён — выберите кандидата, введите id или «без базы».
{{end}}
+
+ {{if .Candidates}}
+
+ | провайдер | название | год | id | |
+
+ {{range .Candidates}}
+
+ | {{.Provider}} |
+ {{.Title}} |
+ {{if .Year}}{{.Year}}{{end}} |
+ {{.ProviderID}} |
+
+
+ |
+
+ {{end}}
+
+
+ {{end}}
+
+
+
+
+