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}}

Переключить тип: @@ -67,6 +67,49 @@
+
+ База метаданных + {{if .Provider}}

Выбрано: {{.Provider}} {{.ProviderID}}

+ {{else if .NoBase}}

Выбрано: без базы (тег папки не ставится)

+ {{else}}

Матч не подтверждён — выберите кандидата, введите id или «без базы».

{{end}} + + {{if .Candidates}} + + + + {{range .Candidates}} + + + + + + + + {{end}} + +
провайдерназваниегодid
{{.Provider}}{{.Title}}{{if .Year}}{{.Year}}{{end}}{{.ProviderID}} +
+ + +
+
+ {{end}} + +
+ Вручную: + + + +
+
+ +
+
+
Файлы → роль