Добавил выбор из кандидатов, если LLM не уверена в раскладке
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 оставляет кандидатов, чьё название совпадает с одним из
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -228,3 +228,92 @@ func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) erro
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Кандидаты базы метаданных (metadata_candidate) ---
|
||||
|
||||
// MetadataCandidate — строка таблицы metadata_candidate. provider/provider_id
|
||||
// хранят значения для тега Jellyfin (напр. TVMaze отдаёт внешний TVDB-id —
|
||||
// см. recognize), а не обязательно нативный id провайдера поиска.
|
||||
type MetadataCandidate struct {
|
||||
ID int64 `db:"id"`
|
||||
RecognitionID int64 `db:"recognition_id"`
|
||||
Provider string `db:"provider"`
|
||||
ProviderID string `db:"provider_id"`
|
||||
Title sql.NullString `db:"title"`
|
||||
Year sql.NullInt64 `db:"year"`
|
||||
Chosen bool `db:"chosen"`
|
||||
CreatedAt string `db:"created_at"`
|
||||
}
|
||||
|
||||
// CreateCandidates вставляет кандидатов распознавания одной транзакцией.
|
||||
func (s *Store) CreateCandidates(ctx context.Context, cands []MetadataCandidate) error {
|
||||
if len(cands) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
const q = `
|
||||
INSERT INTO metadata_candidate (recognition_id, provider, provider_id, title, year)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
for _, c := range cands {
|
||||
if _, err := tx.ExecContext(ctx, q,
|
||||
c.RecognitionID, c.Provider, c.ProviderID, c.Title, c.Year); err != nil {
|
||||
return fmt.Errorf("insert candidate: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit candidates: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListCandidatesByRecognition возвращает кандидатов попытки распознавания.
|
||||
func (s *Store) ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]MetadataCandidate, error) {
|
||||
var out []MetadataCandidate
|
||||
if err := s.DB.SelectContext(ctx, &out,
|
||||
`SELECT * FROM metadata_candidate WHERE recognition_id = ? ORDER BY id`, recognitionID); err != nil {
|
||||
return nil, fmt.Errorf("list candidates: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetCandidate возвращает кандидата по id либо (nil, nil).
|
||||
func (s *Store) GetCandidate(ctx context.Context, id int64) (*MetadataCandidate, error) {
|
||||
var c MetadataCandidate
|
||||
err := s.DB.GetContext(ctx, &c, `SELECT * FROM metadata_candidate WHERE id = ?`, id)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get candidate %d: %w", id, err)
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// SetCandidateChosen помечает кандидата выбранным, снимая отметку с прочих в
|
||||
// той же попытке распознавания.
|
||||
func (s *Store) SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error {
|
||||
tx, err := s.DB.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE metadata_candidate SET chosen = 0 WHERE recognition_id = ?`, recognitionID); err != nil {
|
||||
return fmt.Errorf("clear chosen: %w", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE metadata_candidate SET chosen = 1 WHERE id = ? AND recognition_id = ?`,
|
||||
candidateID, recognitionID); err != nil {
|
||||
return fmt.Errorf("set chosen: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit chosen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -159,6 +159,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)
|
||||
|
||||
+164
-9
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user