Files
jellybit/internal/worker/review_test.go
T
avandClaude Opus 4.8 b1bca98738 Ревью: preflight готовности источника для команд ревью (MAJOR-5)
Команды ревью проверяли только наличие раздачи в qBittorrent, но не её
готовность. Недокачанную задачу можно припарковать в deferred, затем
«Распознать заново» → recognizing → авто-раскладка (Rerecognize/Refine/
SetType не ставят force_review) → хардлинки на неполные файлы. Даже ручной
Apply не имел preflight завершённости.

Вводим ensureSourceReady (classify(t.State)==classReady) вместо
ensureSourcePresent во всех командах, которым нужен источник (Relink/
Rerecognize/Refine/SetType), и inline-проверку класса в Apply — последний
рубеж перед хардлинками. Недокачанный источник → отдельный sentinel
ErrNotReady (409) с actionable-текстом «торрент ещё качается» в web и
Telegram, без reconcile (состояние deferred/review легитимно).

Change review-readiness-preflight заархивирован, дельта влита в
openspec/specs/review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:40:49 +03:00

1543 lines
52 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"
"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) 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, 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)
}
})
}
}