package worker import ( "context" "database/sql" "encoding/json" "io" "log/slog" "os" "path/filepath" "testing" "time" "git.vakhrushev.me/av/jellybit/internal/layout" "git.vakhrushev.me/av/jellybit/internal/metadata" "git.vakhrushev.me/av/jellybit/internal/qbt" "git.vakhrushev.me/av/jellybit/internal/recognize" "git.vakhrushev.me/av/jellybit/internal/store" ) // recordingNotifier ловит события пинга (Notify асинхронен — через канал). type notifyEvent struct { id int64 ev NotifyEvent } type recordingNotifier struct{ ch chan notifyEvent } func (n *recordingNotifier) Notify(_ context.Context, id int64, ev NotifyEvent) { n.ch <- notifyEvent{id, ev} } func waitNotify(t *testing.T, n *recordingNotifier) notifyEvent { t.Helper() select { case e := <-n.ch: return e case <-time.After(2 * time.Second): t.Fatal("пинг не пришёл") return notifyEvent{} } } func TestNotifier_FiresOnReview(t *testing.T) { st := newMemStore() st.put(completedDownload(1)) qb := &fakeQbt{ torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}, files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}}, } w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil) n := &recordingNotifier{ch: make(chan notifyEvent, 4)} w.SetNotifier(n) w.recognizeOne(context.Background(), 1) e := waitNotify(t, n) if e.id != 1 || e.ev != EventReview { t.Errorf("event = %+v, want {1 review}", e) } } func TestNotifier_FiresOnDone(t *testing.T) { f := newApplyFixture(t, seriesResult().Plan) n := &recordingNotifier{ch: make(chan notifyEvent, 4)} f.w.SetNotifier(n) if err := f.w.Apply(context.Background(), 1); err != nil { t.Fatalf("Apply: %v", err) } e := waitNotify(t, n) if e.id != 1 || e.ev != EventDone { t.Errorf("event = %+v, want {1 done}", e) } } // 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 candidates []store.MetadataCandidate } func newMemStore() *memStore { return &memStore{ downloads: map[int64]*store.Download{}, hints: map[int64][]string{}, overrides: map[int64]map[string]string{}, } } func (m *memStore) put(d *store.Download) { m.downloads[d.ID] = d } func (m *memStore) ListDownloadsByState(_ context.Context, states ...store.State) ([]store.Download, error) { var out []store.Download for _, d := range m.downloads { for _, s := range states { if d.State == s { out = append(out, *d) } } } return out, nil } func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, error) { d, ok := m.downloads[id] if !ok { return nil, os.ErrNotExist } cp := *d return &cp, nil } func (m *memStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error { d := m.downloads[id] d.State = st d.ErrorCode = store.NullString(code) d.ErrorMsg = store.NullString(msg) return nil } func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (int64, error) { for _, e := range m.recs { if e.DownloadID == r.DownloadID { e.IsCurrent = false } } cp := *r cp.ID = int64(len(m.recs) + 1) cp.IsCurrent = true cp.AttemptNo = 1 for _, e := range m.recs { if e.DownloadID == r.DownloadID { cp.AttemptNo++ } } b, _ := jsonMarshal(reasons) cp.Reasons = b m.recs = append(m.recs, &cp) return cp.ID, nil } func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID int64) (*store.Recognition, error) { for _, e := range m.recs { if e.DownloadID == downloadID && e.IsCurrent { cp := *e return &cp, nil } } return nil, nil } func (m *memStore) AddHint(_ context.Context, id int64, text string) error { m.hints[id] = append(m.hints[id], text) return nil } func (m *memStore) ListHints(_ context.Context, id int64) ([]string, error) { return m.hints[id], nil } func (m *memStore) SetOverride(_ context.Context, id int64, field, value string) error { if m.overrides[id] == nil { m.overrides[id] = map[string]string{} } m.overrides[id][field] = value return nil } func (m *memStore) ListOverrides(_ context.Context, id int64) (map[string]string, error) { return m.overrides[id], nil } func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) error { m.links = append(m.links, links...) return nil } func (m *memStore) LatestBatchID(_ context.Context, id int64) (string, error) { for i := len(m.links) - 1; i >= 0; i-- { if m.links[i].DownloadID == id { return m.links[i].ApplyBatchID, nil } } return "", nil } func (m *memStore) ListFileLinksByBatch(_ context.Context, batch string) ([]store.FileLink, error) { var out []store.FileLink for _, l := range m.links { if l.ApplyBatchID == batch { out = append(out, l) } } return out, nil } func (m *memStore) DeleteFileLinksByBatch(_ context.Context, batch string) error { kept := m.links[:0] for _, l := range m.links { if l.ApplyBatchID != batch { kept = append(kept, l) } } m.links = kept 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 } // fakeRecognizer возвращает заданный результат; onCall — побочный эффект для // симуляции гонок (напр. отмена во время вызова LLM). type fakeRecognizer struct { result recognize.Result err error onCall func() calls int } func (f *fakeRecognizer) Recognize(_ context.Context, _ recognize.Input) (recognize.Result, error) { f.calls++ if f.onCall != nil { f.onCall() } return f.result, f.err } func testWorkerWith(st Store, qb QBittorrent, rec Recognizer, lay Layouter) *Worker { w := New(st, qb, rec, lay, Config{Category: "jellybit"}, slog.New(slog.NewTextHandler(io.Discard, nil))) n := 0 w.newID = func() string { n++; return "batch-" + itoa(n) } return w } func itoa(n int) string { if n == 0 { return "0" } var b []byte for n > 0 { b = append([]byte{byte('0' + n%10)}, b...) n /= 10 } return string(b) } const ihTest = "541adcff3b6dd5dba7088ea83317d9d6fac331d6" func completedDownload(id int64) *store.Download { return &store.Download{ ID: id, State: store.StateCompleted, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + ihTest, Infohash: store.NullString(ihTest), Context: "ctx", } } func seriesResult() recognize.Result { s, e1, e2 := 2, 1, 2 return recognize.Result{ Plan: recognize.Plan{ Type: recognize.MediaSeries, Title: "Show", Year: 2006, Confidence: 0.7, Files: []recognize.PlanFile{ {Src: "Show/e1.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e1}, {Src: "Show/e2.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e2}, }, }, Decision: recognize.Decision{Reasons: []string{"нет матча в базе"}}, Raw: `{"type":"series"}`, } } func TestRecognizeOne_CompletedToReview(t *testing.T) { st := newMemStore() st.put(completedDownload(1)) qb := &fakeQbt{ torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", Category: "jellybit"}}, files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}, {Name: "Show/e2.mkv", Size: 100}}, } rec := &fakeRecognizer{result: seriesResult()} w := testWorkerWith(st, qb, rec, nil) w.recognizeOne(context.Background(), 1) if st.downloads[1].State != store.StateReview { t.Fatalf("state = %q, want review", st.downloads[1].State) } cur, _ := st.GetCurrentRecognition(context.Background(), 1) if cur == nil || cur.Title.String != "Show" { t.Fatalf("recognition = %+v", cur) } if !cur.Plan.Valid { t.Error("plan must be persisted") } } func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) { st := newMemStore() st.put(completedDownload(1)) qb := &fakeQbt{ torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}, files: []qbt.File{{Name: "Show/e1.mkv", Size: 100}}, } // Во время вызова LLM задачу отменяют. rec := &fakeRecognizer{result: seriesResult(), onCall: func() { st.downloads[1].State = store.StateCancelled }} w := testWorkerWith(st, qb, rec, nil) w.recognizeOne(context.Background(), 1) if st.downloads[1].State != store.StateCancelled { t.Errorf("state = %q, want cancelled (result discarded)", st.downloads[1].State) } if cur, _ := st.GetCurrentRecognition(context.Background(), 1); cur != nil { t.Error("recognition must not be persisted after discard") } } func TestRecognizeOne_SignalsErrorToReview(t *testing.T) { st := newMemStore() st.put(completedDownload(1)) qb := &fakeQbt{torrents: nil} // торрент пропал rec := &fakeRecognizer{result: seriesResult()} w := testWorkerWith(st, qb, rec, nil) w.recognizeOne(context.Background(), 1) if st.downloads[1].State != store.StateReview { t.Fatalf("state = %q, want review", st.downloads[1].State) } cur, _ := st.GetCurrentRecognition(context.Background(), 1) if cur == nil || len(cur.ReasonList()) == 0 { t.Fatal("expected review with reason") } } func TestRefine_AddsHintAndRerecognizes(t *testing.T) { st := newMemStore() d := completedDownload(1) d.State = store.StateReview st.put(d) w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) if err := w.Refine(context.Background(), 1, "это второй сезон"); err != nil { t.Fatalf("Refine: %v", err) } if st.downloads[1].State != store.StateRecognizing { t.Errorf("state = %q, want recognizing", st.downloads[1].State) } if h := st.hints[1]; len(h) != 1 || h[0] != "это второй сезон" { t.Errorf("hints = %v", h) } if err := w.Refine(context.Background(), 1, " "); err == nil { t.Error("empty hint must be rejected") } } func TestSetType(t *testing.T) { st := newMemStore() d := completedDownload(1) d.State = store.StateReview st.put(d) w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) if err := w.SetType(context.Background(), 1, "series"); err != nil { t.Fatalf("SetType: %v", err) } if st.overrides[1][ovrMediaType] != "series" { t.Errorf("override = %v", st.overrides[1]) } if st.downloads[1].State != store.StateRecognizing { t.Errorf("state = %q, want recognizing", st.downloads[1].State) } if err := w.SetType(context.Background(), 1, "cartoon"); err == nil { t.Error("invalid type must be rejected") } } func TestIgnoreFile(t *testing.T) { st := newMemStore() d := completedDownload(1) d.State = store.StateReview st.put(d) w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) if err := w.IgnoreFile(context.Background(), 1, "Show/sample.mkv"); err != nil { t.Fatalf("IgnoreFile: %v", err) } if err := w.IgnoreFile(context.Background(), 1, "Show/sample.mkv"); err != nil { // повтор не дублирует t.Fatalf("IgnoreFile repeat: %v", err) } ignored := parseIgnored(st.overrides[1][ovrIgnoredFiles]) if len(ignored) != 1 || ignored[0] != "Show/sample.mkv" { t.Errorf("ignored = %v", ignored) } if st.downloads[1].State != store.StateReview { t.Errorf("ignore must keep review, got %q", st.downloads[1].State) } } func TestDefer(t *testing.T) { st := newMemStore() d := completedDownload(1) d.State = store.StateReview st.put(d) w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) if err := w.Defer(context.Background(), 1); err != nil { t.Fatalf("Defer: %v", err) } if st.downloads[1].State != store.StateDeferred { t.Errorf("state = %q, want deferred", st.downloads[1].State) } } // applyFixture — реальный layouter с temp-библиотеками и исходными файлами. type applyFixture struct { w *Worker st *memStore downloads string movies string series string } // newApplyFixture готовит worker с реальным layouter: исходные файлы лежат в // downloads (он же savePath торрента), библиотеки — movies/series. func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture { t.Helper() root := t.TempDir() downloads := filepath.Join(root, "downloads") movies := filepath.Join(root, "movies") series := filepath.Join(root, "series") for _, d := range []string{downloads, movies, series} { _ = os.MkdirAll(d, 0o755) } for _, f := range plan.Files { p := filepath.Join(downloads, f.Src) _ = os.MkdirAll(filepath.Dir(p), 0o755) if err := os.WriteFile(p, []byte("data-"+f.Src), 0o644); err != nil { t.Fatal(err) } } lay, err := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}) if err != nil { t.Fatal(err) } st := newMemStore() d := completedDownload(1) d.State = store.StateReview st.put(d) planJSON, _ := json.Marshal(plan) st.recs = append(st.recs, &store.Recognition{ ID: 1, DownloadID: 1, IsCurrent: true, Plan: store.NullString(string(planJSON)), }) qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit"}}} w := testWorkerWith(st, qb, &fakeRecognizer{}, lay) return applyFixture{w: w, st: st, downloads: downloads, movies: movies, series: series} } func TestApply_LinksAndDone(t *testing.T) { f := newApplyFixture(t, seriesResult().Plan) if err := f.w.Apply(context.Background(), 1); err != nil { t.Fatalf("Apply: %v", err) } if f.st.downloads[1].State != store.StateDone { t.Fatalf("state = %q, want done", f.st.downloads[1].State) } if len(f.st.links) != 2 { t.Fatalf("file_links = %d, want 2", len(f.st.links)) } want := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") if _, err := os.Stat(want); err != nil { t.Errorf("expected hardlink %q: %v", want, err) } if _, err := os.Stat(filepath.Join(f.downloads, "Show/e1.mkv")); err != nil { t.Errorf("source must remain: %v", err) } } func TestApply_IgnoredFileSkipped(t *testing.T) { plan := seriesResult().Plan s, e := 2, 9 plan.Files = append(plan.Files, recognize.PlanFile{ Src: "Show/sample.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e, }) f := newApplyFixture(t, plan) _ = f.st.SetOverride(context.Background(), 1, ovrIgnoredFiles, `["Show/sample.mkv"]`) if err := f.w.Apply(context.Background(), 1); err != nil { t.Fatalf("Apply: %v", err) } if len(f.st.links) != 2 { // sample пропущен t.Errorf("file_links = %d, want 2 (sample ignored)", len(f.st.links)) } } func TestApply_CollisionStaysReview(t *testing.T) { plan := seriesResult().Plan f := newApplyFixture(t, plan) // Занимаем цель первой серии чужим файлом. dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") _ = os.MkdirAll(filepath.Dir(dst), 0o755) _ = os.WriteFile(dst, []byte("foreign"), 0o644) err := f.w.Apply(context.Background(), 1) if err == nil { t.Fatal("want collision error") } if f.st.downloads[1].State != store.StateReview { t.Errorf("state = %q, want review after collision", f.st.downloads[1].State) } b, _ := os.ReadFile(dst) if string(b) != "foreign" { t.Errorf("foreign file overwritten: %q", b) } } func TestUndo_RevertsLinks(t *testing.T) { plan := seriesResult().Plan f := newApplyFixture(t, plan) if err := f.w.Apply(context.Background(), 1); err != nil { t.Fatalf("Apply: %v", err) } dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") if _, err := os.Stat(dst); err != nil { t.Fatalf("precondition: link must exist: %v", err) } if err := f.w.Undo(context.Background(), 1); err != nil { t.Fatalf("Undo: %v", err) } if f.st.downloads[1].State != store.StateReverted { t.Errorf("state = %q, want reverted", f.st.downloads[1].State) } if _, err := os.Stat(dst); !os.IsNotExist(err) { t.Errorf("link must be removed: %v", err) } if len(f.st.links) != 0 { t.Errorf("file_links must be deleted, got %d", len(f.st.links)) } // Источник цел. if _, err := os.Stat(filepath.Join(f.downloads, "Show/e1.mkv")); err != nil { t.Errorf("source removed by undo: %v", err) } } func TestReviewData(t *testing.T) { plan := seriesResult().Plan f := newApplyFixture(t, plan) _ = f.st.AddHint(context.Background(), 1, "подсказка") rd, err := f.w.ReviewData(context.Background(), 1) if err != nil { t.Fatalf("ReviewData: %v", err) } if rd.Recognition == nil || len(rd.Plan.Files) != 2 { t.Fatalf("plan files = %+v", rd.Plan) } if len(rd.Preview) != 2 { t.Errorf("preview links = %d, want 2", len(rd.Preview)) } if len(rd.Hints) != 1 { t.Errorf("hints = %v", rd.Hints) } } func TestApplyOverrides(t *testing.T) { plan := recognize.Plan{ Type: recognize.MediaMovie, Files: []recognize.PlanFile{ {Src: "a.mkv", Role: recognize.RoleMain}, {Src: "b.mkv", Role: recognize.RoleEpisode}, }, } out := applyOverrides(plan, map[string]string{ ovrMediaType: "series", ovrIgnoredFiles: `["a.mkv"]`, }) if out.Type != recognize.MediaSeries { t.Errorf("type = %q, want series", out.Type) } if out.Files[0].Role != "ignore" { t.Errorf("a.mkv role = %q, want ignore", out.Files[0].Role) } } func TestRecognizeOne_AutoApplies(t *testing.T) { root := t.TempDir() downloads := filepath.Join(root, "downloads") movies := filepath.Join(root, "movies") series := filepath.Join(root, "series") for _, d := range []string{downloads, movies, series} { _ = os.MkdirAll(d, 0o755) } plan := seriesResult().Plan plan.Confidence = 0.95 for _, f := range plan.Files { p := filepath.Join(downloads, f.Src) _ = os.MkdirAll(filepath.Dir(p), 0o755) _ = os.WriteFile(p, []byte("x"), 0o644) } lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}) st := newMemStore() st.put(completedDownload(1)) qb := &fakeQbt{ torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: downloads, Category: "jellybit"}}, files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}, {Name: "Show/e2.mkv", Size: 1}}, } rec := &fakeRecognizer{result: recognize.Result{ Plan: plan, Decision: recognize.Decision{Auto: true}, Match: &recognize.Match{Provider: "tmdb", ProviderID: "42", Title: "Show", Year: 2006}, }} w := testWorkerWith(st, qb, rec, lay) w.recognizeOne(context.Background(), 1) if st.downloads[1].State != store.StateDone { t.Fatalf("state = %q, want done (auto)", st.downloads[1].State) } // Provider-тег попал в имя папки. want := filepath.Join(series, "Show (2006) [tmdbid-42]", "Season 02", "Show (2006) S02E01.mkv") if _, err := os.Stat(want); err != nil { t.Errorf("expected auto-linked file %q: %v", want, err) } if len(st.links) != 2 { t.Errorf("file_links = %d, want 2", len(st.links)) } } func TestApply_UsesProviderTag(t *testing.T) { f := newApplyFixture(t, seriesResult().Plan) f.st.recs[0].Provider = store.NullString("tmdb") f.st.recs[0].ProviderID = store.NullString("603") if err := f.w.Apply(context.Background(), 1); err != nil { t.Fatalf("Apply: %v", err) } want := filepath.Join(f.series, "Show (2006) [tmdbid-603]", "Season 02", "Show (2006) S02E01.mkv") if _, err := os.Stat(want); err != nil { t.Errorf("expected tagged path %q: %v", want, err) } } func TestProviderTag(t *testing.T) { cases := []struct{ provider, id, want string }{ {"tmdb", "603", "tmdbid-603"}, {"tvdb", "123", "tvdbid-123"}, {"imdb", "tt2802850", "imdbid-tt2802850"}, {"none", "", ""}, {"tmdb", "", ""}, {"weird", "1", ""}, } for _, c := range cases { if got := providerTag(c.provider, c.id); got != c.want { t.Errorf("providerTag(%q,%q) = %q, want %q", c.provider, c.id, got, c.want) } } } // 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{ Type: recognize.MediaSeries, Title: "X", Year: 2020, Files: []recognize.PlanFile{ {Src: "e.mkv", Role: recognize.RoleEpisode, Season: &s, Episode: &e}, {Src: "sample.mkv", Role: "sample"}, }, } lp := toLayoutPlan(plan, "/d", "tmdbid-1") if len(lp.Files) != 1 { t.Fatalf("want 1 linkable file, got %d", len(lp.Files)) } if lp.Files[0].Src != filepath.Join("/d", "e.mkv") { t.Errorf("src = %q", lp.Files[0].Src) } if lp.Files[0].Role != layout.RoleEpisode { t.Errorf("role = %q", lp.Files[0].Role) } if lp.ProviderTag != "tmdbid-1" { t.Errorf("provider tag = %q", lp.ProviderTag) } }