Добавил выбор из кандидатов, если LLM не уверена в раскладке

This commit is contained in:
2026-06-14 16:43:50 +03:00
parent 4af3ad2dde
commit 7f7f5f69d4
16 changed files with 831 additions and 88 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ web_base_url = "" # напр. "http://umbar:8080" — для
[http] [http]
listen = ":8080" listen = ":8080"
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений trusted_subnets = [] # ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN); зарезервировано
[log] [log]
level = "info" level = "info"
+5 -4
View File
@@ -84,9 +84,10 @@ SQLite; на старте `worker` сверяет категорию qBittorrent
reject / defer / undo) — команды к `worker`: reject / defer / undo) — команды к `worker`:
- **HTTP API + веб-UI** — форма «добавить», список, экран ревью - **HTTP API + веб-UI** — форма «добавить», список, экран ревью
(server-rendered + htmx). В v1 **без авторизации** (доверенная LAN), с (server-rendered). В v1 **без авторизации** (доверенная LAN). Поле
опциональным allowlist подсетей (`http.trusted_subnets`). Защиту `http.trusted_subnets` зарезервировано, но **пока не применяется**:
навесим позже — [drafts/ideas.md](../drafts/ideas.md). деплой только в локальную сеть без доступа из интернета, поэтому
allowlist-middleware и авторизацию отложили — [drafts/ideas.md](../drafts/ideas.md).
- **Telegram-бот** — переслать magnet/сообщение бота; текст становится - **Telegram-бот** — переслать magnet/сообщение бота; текст становится
контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет контекстом. Доступ — по `telegram.allowed_user_ids` (пусто = запрет
всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности. всем, fail-closed). Бот же шлёт **пинги** о входе в review/готовности.
@@ -189,7 +190,7 @@ allowed_user_ids = [] # пусто = запрет всем (fail-closed)
[http] [http]
listen = ":8080" listen = ":8080"
trusted_subnets = [] # опц. allowlist подсетей; пусто = без ограничений trusted_subnets = [] # зарезервировано, ПОКА НЕ ПРИМЕНЯЕТСЯ (деплой только в LAN)
[log] [log]
level = "info" level = "info"
+3
View File
@@ -96,6 +96,9 @@ type Telegram struct {
// HTTP — параметры веб-сервера. // HTTP — параметры веб-сервера.
type HTTP struct { type HTTP struct {
Listen string `toml:"listen"` Listen string `toml:"listen"`
// TrustedSubnets — allowlist подсетей. ПОКА НЕ ПРИМЕНЯЕТСЯ: деплой только
// в локальную сеть без доступа из интернета, поэтому middleware отложено
// (см. architecture.md). Поле сохранено под будущую реализацию.
TrustedSubnets []string `toml:"trusted_subnets"` TrustedSubnets []string `toml:"trusted_subnets"`
} }
+3
View File
@@ -87,6 +87,9 @@ func NewRouter(d Deps) (http.Handler, error) {
r.Post("/ui/downloads/{id}/refine", s.handleRefine) r.Post("/ui/downloads/{id}/refine", s.handleRefine)
r.Post("/ui/downloads/{id}/type", s.handleSetType) r.Post("/ui/downloads/{id}/type", s.handleSetType)
r.Post("/ui/downloads/{id}/ignore", s.handleIgnore) r.Post("/ui/downloads/{id}/ignore", s.handleIgnore)
r.Post("/ui/downloads/{id}/candidate", s.handleChooseCandidate)
r.Post("/ui/downloads/{id}/provider", s.handleSetProvider)
r.Post("/ui/downloads/{id}/nobase", s.handleNoBase)
r.Post("/ui/downloads/{id}/defer", s.handleDefer) r.Post("/ui/downloads/{id}/defer", s.handleDefer)
r.Post("/ui/downloads/{id}/undo", s.handleUndo) r.Post("/ui/downloads/{id}/undo", s.handleUndo)
+70 -1
View File
@@ -2,6 +2,7 @@ package httpapi_test
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"io" "io"
"log/slog" "log/slog"
@@ -187,9 +188,12 @@ type fakeReviewer struct {
refined map[int64]string refined map[int64]string
typed map[int64]string typed map[int64]string
ignored map[int64]string ignored map[int64]string
chosen map[int64]int64
providerSet map[int64]string
applied []int64 applied []int64
deferred []int64 deferred []int64
undone []int64 undone []int64
cleared []int64
} }
func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) { func (f *fakeReviewer) ReviewData(_ context.Context, _ int64) (*worker.ReviewData, error) {
@@ -231,6 +235,24 @@ func (f *fakeReviewer) Undo(_ context.Context, id int64) error {
f.undone = append(f.undone, id) f.undone = append(f.undone, id)
return nil return nil
} }
func (f *fakeReviewer) ChooseCandidate(_ context.Context, id, candidateID int64) error {
if f.chosen == nil {
f.chosen = map[int64]int64{}
}
f.chosen[id] = candidateID
return nil
}
func (f *fakeReviewer) SetProviderID(_ context.Context, id int64, provider, providerID string) error {
if f.providerSet == nil {
f.providerSet = map[int64]string{}
}
f.providerSet[id] = provider + ":" + providerID
return nil
}
func (f *fakeReviewer) ClearProvider(_ context.Context, id int64) error {
f.cleared = append(f.cleared, id)
return nil
}
func seriesReviewData() *worker.ReviewData { func seriesReviewData() *worker.ReviewData {
s, e := 2, 1 s, e := 2, 1
@@ -248,6 +270,11 @@ func seriesReviewData() *worker.ReviewData {
Preview: []layout.Link{ Preview: []layout.Link{
{Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"}, {Src: "Fargo/e1.mkv", Dst: "/srv/media/series/Фарго (2015)/Season 02/Фарго (2015) S02E01.mkv"},
}, },
Candidates: []store.MetadataCandidate{
{ID: 10, Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
Year: sql.NullInt64{Int64: 2014, Valid: true}},
{ID: 11, Provider: "tmdb", ProviderID: "60622", Title: store.NullString("Fargo")},
},
Hints: []string{"второй сезон"}, Hints: []string{"второй сезон"},
} }
} }
@@ -274,13 +301,55 @@ func TestReviewRenders(t *testing.T) {
t.Fatalf("status = %d", resp.StatusCode) t.Fatalf("status = %d", resp.StatusCode)
} }
for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv", for _, want := range []string{"Фарго", "нет матча в базе", "Fargo/e1.mkv",
"Season 02", "Применить", "Уточнить"} { "Season 02", "Применить", "Уточнить",
"База метаданных", "269613", "выбрать", "Без базы"} {
if !strings.Contains(string(body), want) { if !strings.Contains(string(body), want) {
t.Errorf("страница ревью не содержит %q", want) t.Errorf("страница ревью не содержит %q", want)
} }
} }
} }
func TestChooseCandidate(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
resp, err := noRedirectClient().PostForm(srv.URL+"/ui/downloads/1/candidate",
map[string][]string{"candidate_id": {"10"}})
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if rv.chosen[1] != 10 {
t.Errorf("ChooseCandidate получил %d", rv.chosen[1])
}
if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, "/review/1") {
t.Errorf("Location = %q", loc)
}
}
func TestSetProviderAndNoBase(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
Reader: &fakeReader{}, Reviewer: rv})
cl := noRedirectClient()
if _, err := cl.PostForm(srv.URL+"/ui/downloads/1/provider",
map[string][]string{"provider": {"tvdb"}, "provider_id": {"269613"}}); err != nil {
t.Fatal(err)
}
if rv.providerSet[1] != "tvdb:269613" {
t.Errorf("SetProviderID получил %q", rv.providerSet[1])
}
if _, err := cl.Post(srv.URL+"/ui/downloads/1/nobase", "", nil); err != nil {
t.Fatal(err)
}
if len(rv.cleared) != 1 || rv.cleared[0] != 1 {
t.Errorf("ClearProvider = %v", rv.cleared)
}
}
func TestApplyRedirectsToIndex(t *testing.T) { func TestApplyRedirectsToIndex(t *testing.T) {
rv := &fakeReviewer{data: seriesReviewData()} rv := &fakeReviewer{data: seriesReviewData()}
srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{}, srv := newServer(t, httpapi.Deps{Ingestor: &fakeIngestor{}, Commander: &fakeCommander{},
+56 -3
View File
@@ -20,6 +20,9 @@ type Reviewer interface {
IgnoreFile(ctx context.Context, id int64, src string) error IgnoreFile(ctx context.Context, id int64, src string) error
Defer(ctx context.Context, id int64) error Defer(ctx context.Context, id int64) error
Undo(ctx context.Context, id int64) error Undo(ctx context.Context, id int64) error
ChooseCandidate(ctx context.Context, id, candidateID int64) error
SetProviderID(ctx context.Context, id int64, provider, providerID string) error
ClearProvider(ctx context.Context, id int64) error
} }
// --- Представление страницы ревью --- // --- Представление страницы ревью ---
@@ -44,6 +47,8 @@ type reviewView struct {
Files []reviewFileView Files []reviewFileView
Preview []string Preview []string
HasPlan bool HasPlan bool
NoBase bool // выбрано «без базы»
Candidates []candidateView
} }
type reviewFileView struct { type reviewFileView struct {
@@ -54,6 +59,15 @@ type reviewFileView struct {
Ignored bool Ignored bool
} }
type candidateView struct {
ID int64
Provider string
ProviderID string
Title string
Year int
Chosen bool
}
func (s *server) handleReview(w http.ResponseWriter, r *http.Request) { func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
@@ -87,9 +101,12 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
view.OriginalTitle = rd.Plan.OriginalTitle view.OriginalTitle = rd.Plan.OriginalTitle
view.Year = rd.Plan.Year view.Year = rd.Plan.Year
view.Reasons = rec.ReasonList() view.Reasons = rec.ReasonList()
if rec.Provider.Valid && rec.Provider.String != "none" { switch rd.Provider {
view.Provider = rec.Provider.String case "", "none":
view.ProviderID = rec.ProviderID.String view.NoBase = rd.Provider == "none"
default:
view.Provider = rd.Provider
view.ProviderID = rd.ProviderID
} }
if rec.Confidence.Valid { if rec.Confidence.Valid {
view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64) view.Confidence = strconv.FormatFloat(rec.Confidence.Float64, 'f', 2, 64)
@@ -104,6 +121,16 @@ func (s *server) handleReview(w http.ResponseWriter, r *http.Request) {
}) })
} }
view.HasPlan = len(rd.Plan.Files) > 0 view.HasPlan = len(rd.Plan.Files) > 0
for _, c := range rd.Candidates {
view.Candidates = append(view.Candidates, candidateView{
ID: c.ID,
Provider: c.Provider,
ProviderID: c.ProviderID,
Title: c.Title.String,
Year: int(c.Year.Int64),
Chosen: c.Chosen,
})
}
} }
for _, l := range rd.Preview { for _, l := range rd.Preview {
view.Preview = append(view.Preview, l.Dst) view.Preview = append(view.Preview, l.Dst)
@@ -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) { func (s *server) handleDefer(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r) id, err := pathID(r)
if err != nil { if err != nil {
+48 -20
View File
@@ -8,14 +8,17 @@ import (
"git.vakhrushev.me/av/jellybit/internal/metadata" "git.vakhrushev.me/av/jellybit/internal/metadata"
) )
// matchMetadata сверяет план с включёнными базами: ищет по названию+году и, // maxCandidates — потолок на число сохраняемых кандидатов для ручного выбора.
// если ровно один кандидат уверенно совпадает (название и год), возвращает const maxCandidates = 8
// матч с официальным id и каноническим именем. Несколько кандидатов или их
// отсутствие → nil (тогда авто-раскладки не будет). Ошибки провайдера не // matchMetadata сверяет план с включёнными базами. Возвращает (а) единичный
// валят распознавание — просто нет матча. // сильный матч — ровно один кандидат с совпадением названия и года (для него
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match { // тянем число серий и используем для авто), либо nil; (б) список кандидатов
// из всех провайдеров (топ-N, дедуп) — чтобы человек мог выбрать в review,
// когда сильного матча нет. Ошибки провайдера не валят распознавание.
func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) (*Match, []metadata.Candidate) {
if len(r.providers) == 0 { if len(r.providers) == 0 {
return nil return nil, nil
} }
mt := metadata.Movie mt := metadata.Movie
if plan.Type == MediaSeries { if plan.Type == MediaSeries {
@@ -28,35 +31,52 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
} }
matchTitles := normSet(plan.Title, plan.OriginalTitle) matchTitles := normSet(plan.Title, plan.OriginalTitle)
var match *Match
var candidates []metadata.Candidate
seen := map[string]bool{}
for _, p := range r.providers { for _, p := range r.providers {
cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year}) cands, err := p.Search(ctx, metadata.Query{Type: mt, Title: searchTitle, Year: plan.Year})
if err != nil { if err != nil {
r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err) r.log.Warn("recognize: metadata search failed", "provider", p.Name(), "err", err)
continue continue
} }
// Копим кандидатов для выбора (дедуп по провайдеру+id, потолок).
for _, c := range cands {
key := c.Provider + ":" + c.ID
if seen[key] || len(candidates) >= maxCandidates {
continue
}
seen[key] = true
candidates = append(candidates, c)
}
// Единичный сильный матч ищем у первого подходящего провайдера.
if match != nil {
continue
}
strong := strongMatches(cands, plan.Year, matchTitles) strong := strongMatches(cands, plan.Year, matchTitles)
if len(strong) != 1 { if len(strong) != 1 {
continue continue
} }
c := strong[0] match = r.buildMatch(ctx, p, strong[0], mt)
}
return match, candidates
}
// Число серий тянем по нативному id провайдера. // 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 var counts map[int]int
if mt == metadata.Series { if mt == metadata.Series {
if got, cerr := p.SeasonEpisodeCounts(ctx, c.ID); cerr == nil { if got, err := p.SeasonEpisodeCounts(ctx, c.ID); err == nil {
counts = got counts = got
} else { } else {
r.log.Warn("recognize: episode counts failed", r.log.Warn("recognize: episode counts failed", "provider", p.Name(), "id", c.ID, "err", err)
"provider", p.Name(), "id", c.ID, "err", cerr)
} }
} }
prov, pid := CandidateTag(c)
// Провенанс и тег папки — по внешнему id, если провайдер его дал
// (TVMaze отдаёт TVDB/IMDb-id); иначе по самому провайдеру.
prov, pid := c.Provider, c.ID
if c.TagProvider != "" {
prov, pid = c.TagProvider, c.TagID
}
return &Match{ return &Match{
Provider: prov, Provider: prov,
ProviderID: pid, ProviderID: pid,
@@ -64,8 +84,16 @@ func (r *Recognizer) matchMetadata(ctx context.Context, plan Plan) *Match {
Year: c.Year, Year: c.Year,
SeasonEpisodeCounts: counts, 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 nil return c.Provider, c.ID
} }
// strongMatches оставляет кандидатов, чьё название совпадает с одним из // strongMatches оставляет кандидатов, чьё название совпадает с одним из
+52 -8
View File
@@ -44,7 +44,7 @@ func TestMatchMetadata_SingleStrong(t *testing.T) {
{Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003}, {Provider: "tmdb", ID: "604", Title: "The Matrix Reloaded", Year: 2003},
}} }}
r := recognizerWith(p) r := recognizerWith(p)
m := r.matchMetadata(context.Background(), m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999}) Plan{Type: MediaMovie, Title: "The Matrix", Year: 1999})
if m == nil { if m == nil {
t.Fatal("expected match") t.Fatal("expected match")
@@ -61,16 +61,60 @@ func TestMatchMetadata_AmbiguousNoMatch(t *testing.T) {
{ID: "2", Title: "Fargo", Year: 2014}, {ID: "2", Title: "Fargo", Year: 2014},
}} }}
r := recognizerWith(p) r := recognizerWith(p)
if m := r.matchMetadata(context.Background(), if m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil { Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}); m != nil {
t.Errorf("ambiguous must not match, got %+v", m) t.Errorf("ambiguous must not match, got %+v", m)
} }
} }
func TestMatchMetadata_ReturnsCandidates(t *testing.T) {
// Нет сильного матча (разные названия), но кандидаты собраны для выбора.
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014, TagProvider: "tvdb", TagID: "269613"},
{Provider: "tvmaze", ID: "2", Title: "Fargo Idaho", Year: 2010},
{Provider: "tvmaze", ID: "1", Title: "Fargo", Year: 2014}, // дубль по id
}}
r := recognizerWith(p)
m, cands := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Совсем другое", Year: 2014})
if m != nil {
t.Errorf("strong match не ожидался: %+v", m)
}
if len(cands) != 2 { // дубль отброшен
t.Fatalf("candidates = %d, want 2: %+v", len(cands), cands)
}
// CandidateTag даёт внешний TVDB-id для первого.
prov, id := CandidateTag(cands[0])
if prov != "tvdb" || id != "269613" {
t.Errorf("tag = %s/%s", prov, id)
}
}
func TestRecognize_PopulatesCandidates(t *testing.T) {
in := Input{Name: "Show.S01", Files: []File{{Path: "e1.mkv", Size: 1}}}
resp := `{"type":"series","title":"Show","year":2020,"confidence":0.9,
"files":[{"src":"e1.mkv","role":"episode","season":1,"episode":1}]}`
p := &fakeProvider{name: "tvmaze", candidates: []metadata.Candidate{
{Provider: "tvmaze", ID: "1", Title: "Show One", Year: 2020},
{Provider: "tvmaze", ID: "2", Title: "Show Two", Year: 2019},
}}
r := New(&fakeLLM{responses: []string{resp}}, []metadata.Provider{p}, Config{}, testLogger())
res, err := r.Recognize(context.Background(), in)
if err != nil {
t.Fatalf("Recognize: %v", err)
}
if res.Match != nil {
t.Errorf("strong match не ожидался")
}
if len(res.Candidates) != 2 {
t.Errorf("Result.Candidates = %d, want 2", len(res.Candidates))
}
}
func TestMatchMetadata_YearMismatch(t *testing.T) { func TestMatchMetadata_YearMismatch(t *testing.T) {
p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}} p := &fakeProvider{candidates: []metadata.Candidate{{ID: "1", Title: "X", Year: 1990}}}
r := recognizerWith(p) r := recognizerWith(p)
if m := r.matchMetadata(context.Background(), if m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil { Plan{Type: MediaMovie, Title: "X", Year: 2020}); m != nil {
t.Errorf("year mismatch must not match, got %+v", m) t.Errorf("year mismatch must not match, got %+v", m)
} }
@@ -81,7 +125,7 @@ func TestMatchMetadata_OriginalTitle(t *testing.T) {
{ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994}, {ID: "1", Title: "Leon", OriginalTitle: "Léon", Year: 1994},
}} }}
r := recognizerWith(p) r := recognizerWith(p)
m := r.matchMetadata(context.Background(), m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "Léon", Year: 1994}) Plan{Type: MediaMovie, Title: "Léon", Year: 1994})
if m == nil || m.ProviderID != "1" { if m == nil || m.ProviderID != "1" {
t.Errorf("should match by original title, got %+v", m) t.Errorf("should match by original title, got %+v", m)
@@ -98,7 +142,7 @@ func TestMatchMetadata_TagFromExternal(t *testing.T) {
counts: map[int]int{1: 10}, counts: map[int]int{1: 10},
} }
r := recognizerWith(p) r := recognizerWith(p)
m := r.matchMetadata(context.Background(), m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}) Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
if m == nil { if m == nil {
t.Fatal("expected match") t.Fatal("expected match")
@@ -118,7 +162,7 @@ func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
counts: map[int]int{1: 10, 2: 10}, counts: map[int]int{1: 10, 2: 10},
} }
r := recognizerWith(p) r := recognizerWith(p)
m := r.matchMetadata(context.Background(), m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaSeries, Title: "Fargo", Year: 2014}) Plan{Type: MediaSeries, Title: "Fargo", Year: 2014})
if m == nil || m.SeasonEpisodeCounts[1] != 10 { if m == nil || m.SeasonEpisodeCounts[1] != 10 {
t.Errorf("counts not fetched: %+v", m) t.Errorf("counts not fetched: %+v", m)
@@ -128,7 +172,7 @@ func TestMatchMetadata_SeriesFetchesCounts(t *testing.T) {
func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) { func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
p := &fakeProvider{searchErr: errors.New("upstream down")} p := &fakeProvider{searchErr: errors.New("upstream down")}
r := recognizerWith(p) r := recognizerWith(p)
if m := r.matchMetadata(context.Background(), if m, _ := r.matchMetadata(context.Background(),
Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil { Plan{Type: MediaMovie, Title: "X", Year: 2000}); m != nil {
t.Errorf("provider error must yield no match, got %+v", m) t.Errorf("provider error must yield no match, got %+v", m)
} }
@@ -136,7 +180,7 @@ func TestMatchMetadata_ProviderErrorNoMatch(t *testing.T) {
func TestMatchMetadata_Disabled(t *testing.T) { func TestMatchMetadata_Disabled(t *testing.T) {
r := recognizerWith(nil) r := recognizerWith(nil)
if m := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil { if m, _ := r.matchMetadata(context.Background(), Plan{Type: MediaMovie, Title: "X"}); m != nil {
t.Errorf("no providers → no match, got %+v", m) t.Errorf("no providers → no match, got %+v", m)
} }
} }
+10 -6
View File
@@ -118,9 +118,10 @@ type Result struct {
Plan Plan Plan Plan
PreParse PreParse PreParse PreParse
Decision Decision Decision Decision
Match *Match // подтверждённый матч в базе (nil — нет) Match *Match // подтверждённый единичный матч (nil — нет)
Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи разбора) Candidates []metadata.Candidate // кандидаты базы для ручного выбора в review
Raw string // сырой ответ LLM последней попытки (для recognition.raw_llm) Attempts int // сколько вызовов LLM понадобилось (вкл. ретраи)
Raw string // сырой ответ LLM последней попытки
} }
// LLM — нужная recognize часть провайдера. // LLM — нужная recognize часть провайдера.
@@ -234,8 +235,9 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
} }
// Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год // Сверка с базой: подтверждаем id + каноническое имя; при матче имя/год
// в плане заменяем на каноничные. // в плане заменяем на каноничные. Кандидаты копим для ручного выбора в
match := r.matchMetadata(ctx, plan) // review, когда единичного сильного матча нет.
match, candidates := r.matchMetadata(ctx, plan)
if match != nil { if match != nil {
plan.Title = match.Title plan.Title = match.Title
if match.Year != 0 { if match.Year != 0 {
@@ -247,12 +249,14 @@ func (r *Recognizer) Recognize(ctx context.Context, in Input) (Result, error) {
r.log.Info("recognize: done", r.log.Info("recognize: done",
"type", plan.Type, "title", plan.Title, "year", plan.Year, "type", plan.Type, "title", plan.Title, "year", plan.Year,
"files", len(plan.Files), "attempts", attempts, "files", len(plan.Files), "attempts", attempts,
"matched", match != nil, "auto", dec.Auto, "reasons", len(dec.Reasons)) "matched", match != nil, "candidates", len(candidates),
"auto", dec.Auto, "reasons", len(dec.Reasons))
return Result{ return Result{
Plan: plan, Plan: plan,
PreParse: pre, PreParse: pre,
Decision: dec, Decision: dec,
Match: match, Match: match,
Candidates: candidates,
Attempts: attempts, Attempts: attempts,
Raw: raw, Raw: raw,
}, nil }, nil
+89
View File
@@ -228,3 +228,92 @@ func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) erro
} }
return nil return nil
} }
// --- Кандидаты базы метаданных (metadata_candidate) ---
// MetadataCandidate — строка таблицы metadata_candidate. provider/provider_id
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
// см. recognize), а не обязательно нативный id провайдера поиска.
type MetadataCandidate struct {
ID int64 `db:"id"`
RecognitionID int64 `db:"recognition_id"`
Provider string `db:"provider"`
ProviderID string `db:"provider_id"`
Title sql.NullString `db:"title"`
Year sql.NullInt64 `db:"year"`
Chosen bool `db:"chosen"`
CreatedAt string `db:"created_at"`
}
// CreateCandidates вставляет кандидатов распознавания одной транзакцией.
func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate) error {
if len(cands) == 0 {
return nil
}
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
const q = `
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year)
VALUES (?, ?, ?, ?, ?)`
for _, c := range cands {
if _, err := tx.ExecContext(ctx, q,
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year); err != nil {
return fmt.Errorf("insert candidate: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit candidates: %w", err)
}
return nil
}
// ListCandidatesByRecognition возвращает кандидатов попытки распознавания.
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]MetadataCandidate, error) {
var out []MetadataCandidate
if err := s.DB.SelectContext(ctx, &out,
`SELECT * FROM metadata_candidate WHERE recognition_id = ? ORDER BY id`, recognitionID); err != nil {
return nil, fmt.Errorf("list candidates: %w", err)
}
return out, nil
}
// GetCandidate возвращает кандидата по id либо (nil, nil).
func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) {
var c MetadataCandidate
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get candidate %d: %w", id, err)
}
return &c, nil
}
// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
// той же попытке распознавания.
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error {
tx, err := s.DB.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`UPDATE metadata_candidate SET chosen = 0 WHERE recognition_id = ?`, recognitionID); err != nil {
return fmt.Errorf("clear chosen: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE metadata_candidate SET chosen = 1 WHERE id = ? AND recognition_id = ?`,
candidateID, recognitionID); err != nil {
return fmt.Errorf("set chosen: %w", err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit chosen: %w", err)
}
return nil
}
+61
View File
@@ -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) { func TestLatestBatchID_None(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
dl := seedDownload(t, st) dl := seedDownload(t, st)
+162 -7
View File
@@ -7,9 +7,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/metadata"
"git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
@@ -19,6 +21,10 @@ import (
const ( const (
ovrMediaType = "media_type" ovrMediaType = "media_type"
ovrIgnoredFiles = "ignored_files" ovrIgnoredFiles = "ignored_files"
ovrProvider = "provider" // выбранная база ("none" = без базы)
ovrProviderID = "provider_id" // id в выбранной базе
ovrTitle = "title" // запиненное каноническое название
ovrYear = "year" // запиненный год
) )
// recognizePending распознаёт завершённые загрузки и перезапускает те, что // recognizePending распознаёт завершённые загрузки и перезапускает те, что
@@ -158,10 +164,17 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
"download_id", id, "state", d.State) "download_id", id, "state", d.State)
return return
} }
if _, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons); err != nil { recID, err := w.store.CreateRecognition(ctx, rec, res.Decision.Reasons)
if err != nil {
w.log.Error("recognize: persist", "download_id", id, "err", err) w.log.Error("recognize: persist", "download_id", id, "err", err)
return return
} }
// Кандидаты базы — для ручного выбора в review.
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
if err := w.store.CreateCandidates(ctx, cands); err != nil {
w.log.Warn("recognize: persist candidates", "download_id", id, "err", err)
}
}
// Авто-раскладка при подтверждённом матче и чистой валидации (Ф4); // Авто-раскладка при подтверждённом матче и чистой валидации (Ф4);
// иначе — review. Раскладчик может быть не сконфигурирован. // иначе — review. Раскладчик может быть не сконфигурирован.
@@ -413,6 +426,94 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
return d, nil return d, nil
} }
// --- Выбор базы метаданных (пиннинг; остаёмся в review, применяет человек) ---
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
// человек подтвердит «Применить».
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) error {
w.mu.Lock()
defer w.mu.Unlock()
if _, err := w.requireReviewable(ctx, id, "choose candidate"); err != nil {
return err
}
rec, err := w.store.GetCurrentRecognition(ctx, id)
if err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
cand, err := w.store.GetCandidate(ctx, candidateID)
if err != nil {
return fmt.Errorf("choose candidate: %w", err)
}
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
return fmt.Errorf("choose candidate: кандидат %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 — всё, что нужно транспорту для отрисовки ревью. // ReviewData — всё, что нужно транспорту для отрисовки ревью.
@@ -421,6 +522,9 @@ type ReviewData struct {
Recognition *store.Recognition Recognition *store.Recognition
Plan recognize.Plan // эффективный (с применёнными правками) Plan recognize.Plan // эффективный (с применёнными правками)
Preview []layout.Link // целевые пути (Src — относительный, для показа) Preview []layout.Link // целевые пути (Src — относительный, для показа)
Candidates []store.MetadataCandidate // кандидаты базы для ручного выбора
Provider string // эффективный провайдер (с учётом выбора)
ProviderID string // эффективный id в базе
Hints []string Hints []string
Overrides map[string]string Overrides map[string]string
} }
@@ -444,7 +548,16 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
return nil, fmt.Errorf("review data: %w", err) return nil, fmt.Errorf("review data: %w", err)
} }
rd := &ReviewData{Download: *d, Recognition: rec, Hints: hints, Overrides: overrides} prov, pid := effectiveProvider(rec, overrides)
rd := &ReviewData{
Download: *d, Recognition: rec, Hints: hints, Overrides: overrides,
Provider: prov, ProviderID: pid,
}
if rec != nil {
if cands, cerr := w.store.ListCandidatesByRecognition(ctx, rec.ID); cerr == nil {
rd.Candidates = cands
}
}
if rec != nil && rec.Plan.Valid { if rec != nil && rec.Plan.Valid {
var plan recognize.Plan var plan recognize.Plan
if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err == nil { if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err == nil {
@@ -453,7 +566,7 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
// Превью строим по относительным путям с provider-тегом; ошибку // Превью строим по относительным путям с provider-тегом; ошибку
// игнорируем — просто покажем причины без превью. // игнорируем — просто покажем причины без превью.
if w.layouter != nil { if w.layouter != nil {
tag := providerTag(rec.Provider.String, rec.ProviderID.String) tag := providerTag(prov, pid)
if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil { if links, lerr := w.layouter.BuildLinks(toLayoutPlan(plan, "", tag)); lerr == nil {
rd.Preview = links rd.Preview = links
} }
@@ -481,18 +594,27 @@ func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, s
if err != nil { if err != nil {
return recognize.Plan{}, "", err return recognize.Plan{}, "", err
} }
tag := providerTag(rec.Provider.String, rec.ProviderID.String) prov, pid := effectiveProvider(rec, overrides)
return applyOverrides(plan, overrides), tag, nil return applyOverrides(plan, overrides), providerTag(prov, pid), nil
} }
// --- Хелперы преобразования --- // --- Хелперы преобразования ---
// applyOverrides применяет ручные правки к плану: форсит тип и помечает // applyOverrides применяет ручные правки к плану: форсит тип, каноническое
// игнорируемые файлы ролью ignore (их раскладка пропустит). // имя/год (из выбранного кандидата базы) и помечает игнорируемые файлы ролью
// ignore (их раскладка пропустит).
func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan { func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.Plan {
if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) { if mt := overrides[ovrMediaType]; mt == string(recognize.MediaMovie) || mt == string(recognize.MediaSeries) {
plan.Type = recognize.MediaType(mt) plan.Type = recognize.MediaType(mt)
} }
if t := overrides[ovrTitle]; t != "" {
plan.Title = t
}
if y := overrides[ovrYear]; y != "" {
if year, err := strconv.Atoi(y); err == nil {
plan.Year = year
}
}
ignored := parseIgnored(overrides[ovrIgnoredFiles]) ignored := parseIgnored(overrides[ovrIgnoredFiles])
if len(ignored) > 0 { if len(ignored) > 0 {
for i := range plan.Files { for i := range plan.Files {
@@ -504,6 +626,39 @@ func applyOverrides(plan recognize.Plan, overrides map[string]string) recognize.
return plan return plan
} }
// effectiveProvider возвращает провайдера и id для тега папки с учётом
// ручного выбора: запиненный override перекрывает распознанный матч.
// override "none" означает явный отказ от базы.
func effectiveProvider(rec *store.Recognition, overrides map[string]string) (provider, id string) {
if p, ok := overrides[ovrProvider]; ok {
return p, overrides[ovrProviderID]
}
if rec != nil {
return rec.Provider.String, rec.ProviderID.String
}
return "", ""
}
// toStoreCandidates переводит кандидатов распознавания в строки БД,
// подставляя тег-предпочтительный provider/id (внешний из TVMaze и т.п.).
func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.MetadataCandidate {
out := make([]store.MetadataCandidate, 0, len(cands))
for _, c := range cands {
prov, id := recognize.CandidateTag(c)
mc := store.MetadataCandidate{
RecognitionID: recognitionID,
Provider: prov,
ProviderID: id,
Title: store.NullString(c.Title),
}
if c.Year != 0 {
mc.Year = sql.NullInt64{Int64: int64(c.Year), Valid: true}
}
out = append(out, mc)
}
return out
}
// providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…" // providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…"
// / "tvdbid-…". Пустой id (нет матча) → пустой тег. // / "tvdbid-…". Пустой id (нет матча) → пустой тег.
func providerTag(provider, id string) string { func providerTag(provider, id string) string {
+174
View File
@@ -2,6 +2,7 @@ package worker
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"io" "io"
"log/slog" "log/slog"
@@ -11,6 +12,7 @@ import (
"time" "time"
"git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/layout"
"git.vakhrushev.me/av/jellybit/internal/metadata"
"git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/qbt"
"git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/recognize"
"git.vakhrushev.me/av/jellybit/internal/store" "git.vakhrushev.me/av/jellybit/internal/store"
@@ -78,6 +80,7 @@ type memStore struct {
hints map[int64][]string hints map[int64][]string
overrides map[int64]map[string]string overrides map[int64]map[string]string
links []store.FileLink links []store.FileLink
candidates []store.MetadataCandidate
} }
func newMemStore() *memStore { func newMemStore() *memStore {
@@ -199,6 +202,40 @@ func (m *memStore) DeleteFileLinksByBatch(_ context.Context, batch string) error
return nil return nil
} }
func (m *memStore) CreateCandidates(_ context.Context, cands []store.MetadataCandidate) error {
for _, c := range cands {
c.ID = int64(len(m.candidates) + 1)
m.candidates = append(m.candidates, c)
}
return nil
}
func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID int64) ([]store.MetadataCandidate, error) {
var out []store.MetadataCandidate
for _, c := range m.candidates {
if c.RecognitionID == recID {
out = append(out, c)
}
}
return out, nil
}
func (m *memStore) GetCandidate(_ context.Context, id int64) (*store.MetadataCandidate, error) {
for i := range m.candidates {
if m.candidates[i].ID == id {
cp := m.candidates[i]
return &cp, nil
}
}
return nil, nil
}
func (m *memStore) SetCandidateChosen(_ context.Context, recID, id int64) error {
for i := range m.candidates {
if m.candidates[i].RecognitionID == recID {
m.candidates[i].Chosen = m.candidates[i].ID == id
}
}
return nil
}
func jsonMarshal(v any) (string, error) { func jsonMarshal(v any) (string, error) {
b, err := json.Marshal(v) b, err := json.Marshal(v)
return string(b), err return string(b), err
@@ -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) { func TestToLayoutPlan(t *testing.T) {
s, e := 1, 3 s, e := 1, 3
plan := recognize.Plan{ plan := recognize.Plan{
+6
View File
@@ -42,6 +42,12 @@ type Store interface {
LatestBatchID(ctx context.Context, downloadID int64) (string, error) LatestBatchID(ctx context.Context, downloadID int64) (string, error)
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error) ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
DeleteFileLinksByBatch(ctx context.Context, batchID string) error DeleteFileLinksByBatch(ctx context.Context, batchID string) error
// Кандидаты базы метаданных (ручной выбор в review).
CreateCandidates(ctx context.Context, cands []store.MetadataCandidate) error
ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]store.MetadataCandidate, error)
GetCandidate(ctx context.Context, id int64) (*store.MetadataCandidate, error)
SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error
} }
// QBittorrent — нужная worker часть клиента qBittorrent. // QBittorrent — нужная worker часть клиента qBittorrent.
+10
View File
@@ -83,6 +83,16 @@ func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.F
return nil, nil return nil, nil
} }
func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error { return nil } func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error { return nil }
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
return nil
}
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ int64) ([]store.MetadataCandidate, error) {
return nil, nil
}
func (f *fakeStore) GetCandidate(_ context.Context, _ int64) (*store.MetadataCandidate, error) {
return nil, nil
}
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ int64) error { return nil }
type fakeQbt struct { type fakeQbt struct {
torrents []qbt.Torrent torrents []qbt.Torrent
+44 -1
View File
@@ -57,7 +57,7 @@
Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b> Тип: <b>{{if .IsSeries}}сериал{{else if eq .MediaType "movie"}}фильм{{else}}{{.MediaType}}{{end}}</b>
· Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}} · Название: <b>{{.Title}}</b>{{if .OriginalTitle}} <small>({{.OriginalTitle}})</small>{{end}}
{{if .Year}}· Год: <b>{{.Year}}</b>{{end}} {{if .Year}}· Год: <b>{{.Year}}</b>{{end}}
{{if .Provider}}· База: <b>{{.Provider}}</b> {{.ProviderID}}{{end}} {{if .Provider}}· База: <b>{{.Provider}}</b> {{.ProviderID}}{{else if .NoBase}}· База: <b>без базы</b>{{end}}
</p> </p>
<form class="row" method="post" action="/ui/downloads/{{.ID}}/type"> <form class="row" method="post" action="/ui/downloads/{{.ID}}/type">
Переключить тип: Переключить тип:
@@ -67,6 +67,49 @@
</form> </form>
</section> </section>
<section>
<strong>База метаданных</strong>
{{if .Provider}}<p>Выбрано: <b>{{.Provider}}</b> {{.ProviderID}}</p>
{{else if .NoBase}}<p>Выбрано: <b>без базы</b> (тег папки не ставится)</p>
{{else}}<p><small>Матч не подтверждён — выберите кандидата, введите id или «без базы».</small></p>{{end}}
{{if .Candidates}}
<table>
<thead><tr><th>провайдер</th><th>название</th><th>год</th><th>id</th><th></th></tr></thead>
<tbody>
{{range .Candidates}}
<tr>
<td>{{.Provider}}</td>
<td>{{.Title}}</td>
<td>{{if .Year}}{{.Year}}{{end}}</td>
<td class="src">{{.ProviderID}}</td>
<td>
<form method="post" action="/ui/downloads/{{$.ID}}/candidate">
<input type="hidden" name="candidate_id" value="{{.ID}}">
<button type="submit" {{if .Chosen}}disabled{{end}}>{{if .Chosen}}выбрано{{else}}выбрать{{end}}</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
<form class="row" method="post" action="/ui/downloads/{{.ID}}/provider">
Вручную:
<select name="provider">
<option value="tvdb">tvdb</option>
<option value="tmdb">tmdb</option>
<option value="imdb">imdb</option>
</select>
<input type="text" name="provider_id" placeholder="id (напр. 269613)" required>
<button type="submit">задать id</button>
</form>
<form method="post" action="/ui/downloads/{{.ID}}/nobase" style="margin-top:.4rem">
<button type="submit">Без базы</button>
</form>
</section>
<section> <section>
<strong>Файлы → роль</strong> <strong>Файлы → роль</strong>
<table> <table>