package worker import ( "context" "database/sql" "encoding/json" "errors" "fmt" "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 string ev NotifyEvent } type recordingNotifier struct{ ch chan notifyEvent } func (n *recordingNotifier) Notify(_ context.Context, id string, 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) } } // recordingScanner ловит вызовы пересканирования Jellyfin (RefreshLibraries // асинхронен — через канал). type recordingScanner struct{ ch chan struct{} } func (s *recordingScanner) RefreshLibraries(_ context.Context) error { s.ch <- struct{}{} return nil } func TestScanner_FiresOnDone(t *testing.T) { f := newApplyFixture(t, seriesResult().Plan) s := &recordingScanner{ch: make(chan struct{}, 4)} f.w.SetScanner(s) if err := f.w.Apply(context.Background(), "1"); err != nil { t.Fatalf("Apply: %v", err) } select { case <-s.ch: case <-time.After(2 * time.Second): t.Fatal("пересканирование Jellyfin не запустилось") } } func revertedDownload(id string) *store.Download { d := completedDownload(id) d.State = store.StateReverted return d } func TestRelink_RevertedToRecognizing(t *testing.T) { st := newMemStore() st.put(revertedDownload("1")) qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", State: "uploading"}}} w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil) if err := w.Relink(context.Background(), "1"); err != nil { t.Fatalf("Relink: %v", err) } if st.downloads["1"].State != store.StateRecognizing { t.Fatalf("state = %q, want recognizing", st.downloads["1"].State) } if st.overrides["1"][ovrForceReview] != "1" { t.Errorf("force_review override = %q, want 1", st.overrides["1"][ovrForceReview]) } } func TestRelink_CancelledToRecognizing(t *testing.T) { st := newMemStore() d := revertedDownload("1") d.State = store.StateCancelled st.put(d) qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", State: "uploading"}}} w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil) if err := w.Relink(context.Background(), "1"); err != nil { t.Fatalf("Relink: %v", err) } if st.downloads["1"].State != store.StateRecognizing { t.Fatalf("state = %q, want recognizing", st.downloads["1"].State) } if st.overrides["1"][ovrForceReview] != "1" { t.Errorf("force_review override = %q, want 1", st.overrides["1"][ovrForceReview]) } } func TestRelink_RejectsActiveState(t *testing.T) { st := newMemStore() st.put(completedDownload("1")) // не reverted/cancelled qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) if err := w.Relink(context.Background(), "1"); err == nil { t.Fatal("ожидали ошибку для не-reverted/cancelled задачи, получили nil") } } func TestRerecognize_ReviewToRecognizing(t *testing.T) { st := newMemStore() d := completedDownload("1") d.State = store.StateReview st.put(d) qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}} w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) if err := w.Rerecognize(context.Background(), "1"); err != nil { t.Fatalf("Rerecognize: %v", err) } if st.downloads["1"].State != store.StateRecognizing { t.Fatalf("state = %q, want recognizing", st.downloads["1"].State) } } func TestRerecognize_RejectsNonReview(t *testing.T) { st := newMemStore() st.put(completedDownload("1")) // completed, не review/deferred w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) if err := w.Rerecognize(context.Background(), "1"); err == nil { t.Fatal("ожидали ошибку для не-review задачи, получили nil") } } func TestRelink_TorrentMissing(t *testing.T) { st := newMemStore() st.put(revertedDownload("1")) qb := &fakeQbt{torrents: nil} // раздачи в qBittorrent нет w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) if err := w.Relink(context.Background(), "1"); err == nil { t.Fatal("ожидали ошибку при отсутствии торрента, получили nil") } // Preflight приводит состояние к реальности: источника нет и цели нет // (reverted — ссылки сняты) → deleted (см. state-reconciliation). if st.downloads["1"].State != store.StateDeleted { t.Errorf("state = %q, want deleted (preflight привёл к реальности)", st.downloads["1"].State) } } // TestRelink_ForceReviewSkipsAuto проверяет, что после перепривязки даже // уверенный матч не уходит в авто-раскладку, а ждёт подтверждения в review. func TestRelink_ForceReviewSkipsAuto(t *testing.T) { f := newApplyFixture(t, seriesResult().Plan) // Готовим состояние «как после Relink»: reverted, force_review выставлен. f.st.downloads["1"].State = store.StateReverted _ = f.st.SetOverride(context.Background(), "1", ovrForceReview, "1") auto := seriesResult() auto.Decision.Auto = true auto.Match = &recognize.Match{Provider: "tvdb", ProviderID: "42"} f.w.recognizer = &fakeRecognizer{result: auto} if err := f.w.Relink(context.Background(), "1"); err != nil { t.Fatalf("Relink: %v", err) } f.w.recognizeOne(context.Background(), "1") if f.st.downloads["1"].State != store.StateReview { t.Fatalf("state = %q, want review (авто-раскладка не должна сработать)", f.st.downloads["1"].State) } if len(f.st.links) != 0 { t.Errorf("file_links = %d, want 0 (ничего не линковали)", len(f.st.links)) } } // TestReviewCommands_RejectNotReadySource — readiness-preflight: команды, // вводящие задачу в recognizing, отклоняют недокачанный (downloading-класс) // источник с ErrNotReady и НЕ трогают состояние. Иначе недокачанную задачу // можно было бы провести в авто-раскладку и захардлинкать неполные файлы. func TestReviewCommands_RejectNotReadySource(t *testing.T) { notReady := []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d", State: "downloading"}} // review/deferred-команды на задаче в deferred/review. cases := []struct { name string state store.State call func(w *Worker) error }{ {"rerecognize", store.StateDeferred, func(w *Worker) error { return w.Rerecognize(context.Background(), "1") }}, {"refine", store.StateReview, func(w *Worker) error { return w.Refine(context.Background(), "1", "подсказка") }}, {"set type", store.StateReview, func(w *Worker) error { return w.SetType(context.Background(), "1", "series") }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { st := newMemStore() d := completedDownload("1") d.State = tc.state st.put(d) w := testWorkerWith(st, &fakeQbt{torrents: notReady}, &fakeRecognizer{}, nil) if err := tc.call(w); !errors.Is(err, ErrNotReady) { t.Fatalf("err = %v, want ErrNotReady", err) } if got := st.downloads["1"].State; got != tc.state { t.Errorf("state = %q, want %q (не тронуто)", got, tc.state) } }) } // Relink из reverted — тоже требует готовности источника. t.Run("relink", func(t *testing.T) { st := newMemStore() st.put(revertedDownload("1")) w := testWorkerWith(st, &fakeQbt{torrents: notReady}, &fakeRecognizer{}, nil) if err := w.Relink(context.Background(), "1"); !errors.Is(err, ErrNotReady) { t.Fatalf("err = %v, want ErrNotReady", err) } if got := st.downloads["1"].State; got != store.StateReverted { t.Errorf("state = %q, want reverted (не тронуто)", got) } if _, ok := st.overrides["1"][ovrForceReview]; ok { t.Errorf("force_review проставлен, а не должен: отказ до записи override") } }) } // TestApply_RejectsNotReadySource — последний рубеж: Apply не создаёт хардлинки // на недокачанный источник, даже если задача оказалась в review. func TestApply_RejectsNotReadySource(t *testing.T) { st := newMemStore() d := completedDownload("1") d.State = store.StateReview st.put(d) planJSON, _ := json.Marshal(seriesResult().Plan) st.recs = append(st.recs, &store.Recognition{ ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)), }) lay, err := layout.New(layout.Config{MoviesDir: t.TempDir(), SeriesDir: t.TempDir()}, nil) if err != nil { t.Fatal(err) } qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: "/d", State: "downloading"}}} w := testWorkerWith(st, qb, &fakeRecognizer{}, lay) if err := w.Apply(context.Background(), "1"); !errors.Is(err, ErrNotReady) { t.Fatalf("err = %v, want ErrNotReady", err) } if got := st.downloads["1"].State; got != store.StateReview { t.Errorf("state = %q, want review (не тронуто)", got) } if len(st.links) != 0 { t.Errorf("file_links = %d, want 0 (хардлинки не создаём)", len(st.links)) } } // memStore — полноценный in-memory store для тестов Ф3. type memStore struct { downloads map[string]*store.Download recs []*store.Recognition hints map[string][]string overrides map[string]map[string]string links []store.FileLink candidates []store.MetadataCandidate torrents map[string][]byte } func newMemStore() *memStore { return &memStore{ downloads: map[string]*store.Download{}, hints: map[string][]string{}, overrides: map[string]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) ListRecoverable(_ context.Context, codes ...string) ([]store.Download, error) { var out []store.Download for _, d := range m.downloads { if d.State != store.StateFailed && d.State != store.StateStuck { continue } for _, c := range codes { if d.ErrorCode.Valid && d.ErrorCode.String == c { out = append(out, *d) break } } } return out, nil } func (m *memStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) { for _, d := range m.downloads { if hasAnyHash(d, hashes) { return true, nil } } return false, nil } func (m *memStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) { for _, d := range m.downloads { if hasAnyHash(d, hashes) && !d.State.IsTerminal() { cp := *d return &cp, nil } } return nil, nil } func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string, torrentBlob []byte) (*store.Download, error) { if existing, _ := m.FindActiveByInfohash(ctx, hashes...); existing != nil { return existing, nil } id := itoa(len(m.downloads) + 1) cp := *d cp.ID = id for _, h := range hashes { h = store.NormalizeHash(h) cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)}) } m.downloads[id] = &cp if len(torrentBlob) > 0 { if m.torrents == nil { m.torrents = map[string][]byte{} } m.torrents[id] = torrentBlob } d.ID = id d.Infohashes = cp.Infohashes return nil, nil } func (m *memStore) GetTorrentData(_ context.Context, downloadID string) ([]byte, error) { if data, ok := m.torrents[downloadID]; ok { return data, nil } return nil, store.ErrNotFound } func (m *memStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error { d, ok := m.downloads[id] if !ok { return os.ErrNotExist } for _, other := range m.downloads { if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) { return store.ErrInfohashTaken } } return m.SetDownloadState(ctx, id, st, code, msg) } func (m *memStore) AddInfohashes(_ context.Context, id string, hashes []string) error { d, ok := m.downloads[id] if !ok { return os.ErrNotExist } for _, h := range hashes { h = store.NormalizeHash(h) if !hasAnyHash(d, []string{h}) { d.Infohashes = append(d.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)}) } } return nil } func (m *memStore) GetDownload(_ context.Context, id string) (*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 string, 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) PromoteCatched(_ context.Context, id, displayName string) error { d, ok := m.downloads[id] if !ok || d.State != store.StateCatched { return fmt.Errorf("promote catched %s: not in catched", id) } d.State = store.StateDownloading d.DisplayName = displayName return nil } func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error { if d, ok := m.downloads[id]; ok { d.SourceMissCount = n } return nil } func (m *memStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error { if d, ok := m.downloads[id]; ok && !d.SourceAddedAt.Valid { d.SourceAddedAt = store.NullString(store.FormatTime(t)) } return nil } func (m *memStore) SetRetriedAt(_ context.Context, id string, t time.Time) error { if d, ok := m.downloads[id]; ok { d.RetriedAt = store.NullString(store.FormatTime(t)) } return nil } func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (string, error) { for _, e := range m.recs { if e.DownloadID == r.DownloadID { e.IsCurrent = false } } cp := *r cp.ID = itoa(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 string) (*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 string, text string) error { m.hints[id] = append(m.hints[id], text) return nil } func (m *memStore) ListHints(_ context.Context, id string) ([]string, error) { return m.hints[id], nil } func (m *memStore) SetOverride(_ context.Context, id string, 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 string) (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) SupersedeForeignLinks(_ context.Context, downloadID string, dstPaths []string) error { if len(dstPaths) == 0 { return nil } want := make(map[string]bool, len(dstPaths)) for _, p := range dstPaths { want[p] = true } for i := range m.links { l := &m.links[i] if l.DownloadID == downloadID || !want[l.DstPath] { continue } if isLaidOut(l.Status) { l.Status = string(layout.StatusSuperseded) } } return nil } func (m *memStore) LatestBatchID(_ context.Context, id string) (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 = itoa(len(m.candidates) + 1) m.candidates = append(m.candidates, c) } return nil } func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID string) ([]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 string) (*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 string) 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 string) *store.Download { return &store.Download{ ID: id, State: store.StateCompleted, SourceType: store.SourceMagnet, SourceRef: "magnet:?xt=urn:btih:" + ihTest, Infohashes: hashesOf(id, 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") } } // TestRecognizeOne_FindsTagAdoptedTorrent — регрессия: раздача, усыновлённая // по тегу, имеет чужую (или пустую) категорию. Поиск по infohash при // распознавании обязан её найти; раньше фильтр по w.cfg.Category её терял и // распознавание падало с «torrent not found in qBittorrent». func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) { st := newMemStore() st.put(completedDownload("1")) qb := &fakeQbt{ torrents: []qbt.Torrent{{ Hash: ihTest, Name: "ThePitt", SavePath: "/d", Category: "movies", Tags: "jellybit", // тег наш, категория чужая }}, files: []qbt.File{{Name: "ThePitt/e1.mkv", Size: 100}, {Name: "ThePitt/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) } // Recognizer вернул бы Title="Show" только если торрент найден по infohash; // при потере (фильтр по категории) был бы пустой план с причиной «not found». cur, _ := st.GetCurrentRecognition(context.Background(), "1") if cur == nil || cur.Title.String != "Show" { t.Fatalf("recognizer did not run on found torrent (title=%q): torrent must be found by infohash despite foreign category", func() string { if cur == nil { return "" } return cur.Title.String }()) } } 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) qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}} w := testWorkerWith(st, qb, &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) qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, State: "uploading"}}} w := testWorkerWith(st, qb, &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}, nil) 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", State: "uploading"}}} 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 TestApply_SupersedesForeignOwnerOfPath(t *testing.T) { // Прежняя загрузка (id=2) была разложена по тому же пути, её файл удалён. // Новая раскладка (id=1) ложится на освободившийся путь и забирает владение. plan := seriesResult().Plan f := newApplyFixture(t, plan) e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") f.st.put(completedDownload("2")) f.st.links = append(f.st.links, store.FileLink{ DownloadID: "2", ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01, Kind: "video", Status: "linked", }) if err := f.w.Apply(context.Background(), "1"); err != nil { t.Fatalf("Apply: %v", err) } // Чужая ссылка на перехваченный путь — superseded. var foreign *store.FileLink for i := range f.st.links { if f.st.links[i].DownloadID == "2" { foreign = &f.st.links[i] } } if foreign == nil || foreign.Status != string(layout.StatusSuperseded) { t.Errorf("чужая ссылка status = %v, want superseded", foreign) } // Свои ссылки (id=1) не тронуты — download_id != self. for _, l := range f.st.links { if l.DownloadID == "1" && !isLaidOut(l.Status) { t.Errorf("своя ссылка %q стала %q, ожидали разложенную", l.DstPath, l.Status) } } // Прежняя загрузка больше не владеет путём → цель отсутствует. present, err := f.w.targetPresent(context.Background(), "2") if err != nil { t.Fatalf("targetPresent: %v", err) } if present { t.Error("targetPresent(2) = true, want false (путь забран)") } } func TestApply_CollisionKeepsForeignOwner(t *testing.T) { // Если файл прежней загрузки ВСЁ ЕЩЁ на месте — коллизия → review, владение // не отбирается (supersede не срабатывает для не-разложенного пути). plan := seriesResult().Plan f := newApplyFixture(t, plan) e01 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") _ = os.MkdirAll(filepath.Dir(e01), 0o755) _ = os.WriteFile(e01, []byte("foreign"), 0o644) f.st.put(completedDownload("2")) f.st.links = append(f.st.links, store.FileLink{ DownloadID: "2", ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01, Kind: "video", Status: "linked", }) if err := f.w.Apply(context.Background(), "1"); err == nil { t.Fatal("want collision error") } for _, l := range f.st.links { if l.DownloadID == "2" && l.Status != "linked" { t.Errorf("чужая ссылка стала %q при коллизии, владение не должно отбираться", l.Status) } } } 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}, nil) 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", URL: "https://www.tvmaze.com/shows/1"}, {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]) } // URL первого кандидата сохранён. if st.candidates[0].URL.String != "https://www.tvmaze.com/shows/1" || !st.candidates[0].URL.Valid { t.Errorf("candidate[0].URL = (%q, valid=%v), want url", st.candidates[0].URL.String, st.candidates[0].URL.Valid) } // У второго кандидата URL не задан — в БД должен быть NULL (Valid=false). if st.candidates[1].URL.Valid { t.Error("candidate[1].URL must be NULL (Valid=false) for candidate without URL") } } 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) } } // TestClearProvider_ClearsInheritedTitle — регрессия на залипший override // (решение 1a): после выбора титульного кандидата переключение на нейронку // возвращает название/год плана, а не унаследованные от кандидата. func TestClearProvider_ClearsInheritedTitle(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) } if err := w.ClearProvider(context.Background(), "1"); err != nil { t.Fatalf("ClearProvider: %v", err) } // Название/год снова из плана распознавания, тег пуст. plan, tag, err := w.effectivePlan(context.Background(), "1") if err != nil { t.Fatalf("effectivePlan: %v", err) } if plan.Title != "Догадка" || plan.Year != 2000 { t.Errorf("plan = %q (%d), want plan-defaults", plan.Title, plan.Year) } if tag != "" { t.Errorf("tag = %q, want empty", tag) } if ov := st.overrides["1"]; ov[ovrTitle] != "" || ov[ovrYear] != "" { t.Errorf("title/year override not cleared: %v", ov) } } // TestAddManualSource_ChoosesAndClears — ручной источник создаётся, выбирается // и (будучи безтайтловым) очищает унаследованный пин названия. func TestAddManualSource_ChoosesAndClears(t *testing.T) { w, st := reviewWithCandidate(t, store.MetadataCandidate{ Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"), Year: sql.NullInt64{Int64: 2014, Valid: true}, }) if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil { t.Fatalf("ChooseCandidate: %v", err) } if err := w.AddManualSource(context.Background(), "1", "TMDB", " 603 "); err != nil { t.Fatalf("AddManualSource: %v", err) } if len(st.candidates) != 2 { t.Fatalf("candidates = %d, want 2 (manual added)", len(st.candidates)) } plan, tag, err := w.effectivePlan(context.Background(), "1") if err != nil { t.Fatalf("effectivePlan: %v", err) } if plan.Title != "Догадка" { // безтайтловый источник → название из плана t.Errorf("plan title = %q, want plan-default", plan.Title) } if tag != "tmdbid-603" { t.Errorf("tag = %q", tag) } } func TestAddManualSource_DedupAndInvalid(t *testing.T) { w, st := reviewWithCandidate(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "269613"}) // Дубль (provider:id уже есть) → не плодим строку, выбираем существующую. if err := w.AddManualSource(context.Background(), "1", "tvdb", "269613"); err != nil { t.Fatalf("AddManualSource dup: %v", err) } if len(st.candidates) != 1 { t.Errorf("candidates = %d, want 1 (dedup)", len(st.candidates)) } if !st.candidates[0].Chosen { t.Error("существующий кандидат должен стать выбранным") } if err := w.AddManualSource(context.Background(), "1", "kinopoisk", "1"); err == nil { t.Error("недопустимый провайдер должен отклоняться") } if err := w.AddManualSource(context.Background(), "1", "tmdb", " "); err == nil { t.Error("пустой id должен отклоняться") } } func TestReviewData_BuildsSources(t *testing.T) { w, st := reviewWithCandidate(t, store.MetadataCandidate{ Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"), }) rd, err := w.ReviewData(context.Background(), "1") if err != nil { t.Fatalf("ReviewData: %v", err) } // Построение источников (в т.ч. предпросмотр) не должно писать overrides. if ov := st.overrides["1"]; len(ov) != 0 { t.Errorf("ReviewData записал overrides: %v", ov) } if len(rd.Sources) != 2 { t.Fatalf("sources = %d, want 2 (neural + candidate)", len(rd.Sources)) } if rd.Sources[0].Kind != SourceNeural || !rd.Sources[0].Active { t.Errorf("source[0] = %+v, want активная нейронка", rd.Sources[0]) } if rd.Sources[0].Title != "Догадка" { t.Errorf("neural title = %q, want plan title", rd.Sources[0].Title) } if rd.Sources[1].Kind != SourceCandidate || rd.Sources[1].Title != "Fargo" || rd.Sources[1].Active { t.Errorf("source[1] = %+v, want неактивный кандидат Fargo", rd.Sources[1]) } } // TestReviewData_SourcePreviewMatchesChosen — предпросмотр источника совпадает // с превью активного источника после его выбора (эфемерное превью == то, что // применится). Требует реального layouter. func TestReviewData_SourcePreviewMatchesChosen(t *testing.T) { f := newApplyFixture(t, seriesResult().Plan) _ = f.st.CreateCandidates(context.Background(), []store.MetadataCandidate{ {RecognitionID: "1", Provider: "tvdb", ProviderID: "269613"}, }) candID := f.st.candidates[0].ID rd, err := f.w.ReviewData(context.Background(), "1") if err != nil { t.Fatalf("ReviewData: %v", err) } var srcPreview []layout.Link for _, s := range rd.Sources { if s.Kind == SourceCandidate && s.ProviderID == "269613" { srcPreview = s.Preview } } if len(srcPreview) == 0 { t.Fatal("предпросмотр кандидата пуст") } if err := f.w.ChooseCandidate(context.Background(), "1", candID); err != nil { t.Fatalf("ChooseCandidate: %v", err) } rd2, err := f.w.ReviewData(context.Background(), "1") if err != nil { t.Fatalf("ReviewData: %v", err) } if !sameLinks(srcPreview, rd2.Preview) { t.Errorf("превью источника != превью активного:\n src=%v\n act=%v", srcPreview, rd2.Preview) } } func sameLinks(a, b []layout.Link) bool { if len(a) != len(b) { return false } for i := range a { if a[i].Src != b[i].Src || a[i].Dst != b[i].Dst { return false } } return true } 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 TestToStoreCandidates_URL(t *testing.T) { // Кандидат с URL: URL должен быть проброшен как непустой NullString. // Кандидат без URL: URL должен быть пустым NullString (Valid=false → NULL). candURL := toStoreCandidates("1", []metadata.Candidate{ {Provider: "tmdb", ID: "603", Title: "With URL", URL: "https://www.themoviedb.org/movie/603"}, {Provider: "tvdb", ID: "1", Title: "Without URL", URL: ""}, }) if len(candURL) != 2 { t.Fatalf("len = %d, want 2", len(candURL)) } if c := candURL[0]; c.URL.String != "https://www.themoviedb.org/movie/603" || !c.URL.Valid { t.Errorf("URL[0] = (%q, valid=%v), want (url, true)", c.URL.String, c.URL.Valid) } if c := candURL[1]; c.URL.String != "" || c.URL.Valid { t.Errorf("URL[1] = (%q, valid=%v), want (\"\", false)", c.URL.String, c.URL.Valid) } } 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) } } // TestToLayoutPlan_SrcPrefixIsSavePath фиксирует семантику префикса: имена // файлов из qBittorrent /torrents/files относительны save_path и уже содержат // корневую папку для многофайловых раздач. Префикс — save_path, а не // content_path (иначе корневая папка удвоилась бы, а однофайловая раздача // получила бы путь под самим файлом). Это регрессионный страж против правки // префикса на content_path. func TestToLayoutPlan_SrcPrefixIsSavePath(t *testing.T) { const savePath = "/srv/media/downloads" s, e := 1, 1 cases := []struct { name string src string want string }{ // Многофайловая раздача: имя включает корневую папку торрента. {"multi-file", "Show.S01/e1.mkv", filepath.Join(savePath, "Show.S01/e1.mkv")}, // Однофайловая раздача: имя — просто файл (content_path = save_path+файл). {"single-file", "movie.mkv", filepath.Join(savePath, "movie.mkv")}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { plan := recognize.Plan{ Type: recognize.MediaMovie, Title: "X", Year: 2020, Files: []recognize.PlanFile{ {Src: tc.src, Role: recognize.RoleMain, Season: &s, Episode: &e}, }, } lp := toLayoutPlan(plan, savePath, "") if len(lp.Files) != 1 { t.Fatalf("want 1 file, got %d", len(lp.Files)) } if lp.Files[0].Src != tc.want { t.Errorf("src = %q, want %q", lp.Files[0].Src, tc.want) } }) } }