Files
jellybit/internal/worker/review_test.go
T
avandClaude Opus 4.8 aac3ced262 Реализовал выбор источника и предпросмотр в ревью (review-source-selection)
Экран ревью теперь показывает единый список источников совпадения: строка
«распознано нейронкой» наравне с кандидатами баз; выбор/переключение/снятие
в пользу нейронки; ручное добавление по id или URL (TMDB/IMDb — по URL,
TVDB — по числовому id); предпросмотр полей и целевых путей каждого источника
до применения (место под режиссёра зарезервировано). «Раскладка» осталась
отдельной секцией для активного источника, инлайн-превью неактивных — по клику.

Ядро: единая деривация «источник → overrides» (sourcePins), общая для превью
и коммита → preview == apply; заодно чинит латентный залипший override
title/year при переключении источника. Превью считается эфемерно, без записи
в БД; пользовательский URL только парсится (SSRF нет).

Ревью дизайна и кода пройдены; правки ревьюеров учтены (сообщение об ошибке
ручного ввода доходит до пользователя, URL без схемы принимается).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:29:27 +03:00

1436 lines
48 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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"}}}
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"}}}
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}}}
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))
}
}
// 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
}
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) (*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
d.ID = id
d.Infohashes = cp.Infohashes
return nil, nil
}
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) 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) 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 "<nil>"
}
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}}}
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}}}
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"}}}
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)
}
})
}
}