Files
jellybit/internal/worker/review_test.go
T
avandClaude Opus 4.8 0c9421f4c1 Имя: восстановление display_name после распознавания + гард пустого входа
Голый magnet без dn/контекста заставлял namer звать LLM на пустом входе,
и модель галлюцинировала мусорное имя («Unknown»), которое писалось и в
display_name, и в rename qBittorrent, а заодно ломало UI-фолбэк на
распознанное название. Верное каноническое имя, вычисляемое позже при
распознавании, никуда не переливалось.

- naming: гард пустого входа в DeriveName (нет контекста и подсказки → ""
  без вызова LLM) + детерминированный форматтер FormatTitleYear.
- qbt: операция RenameTorrent (переименование существующей раздачи).
- store: SetDisplayName — обновление имени постфактум без гарда состояния.
- worker: refreshDisplayNameLocked/RefreshDisplayName — перелив канонического
  имени (эффективный план) в display_name + best-effort rename раздачи по
  реальному t.Hash; авто-триггер при подтверждении матча (choose/manual add).
- web-ui: кнопка «Обновить имя» на странице загрузки (htmx-своп заголовка,
  деградация без JS), видимая при наличии распознавания (вкл. done/orphaned).

Спека: дельты ingest/review/web-ui влиты в openspec/specs; change
refresh-display-name заархивирован.

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

2160 lines
79 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"
"strings"
"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
// Инъекция сбоев (для тестов устойчивости раскладки).
failCreateLinks error // CreateFileLinks вернёт эту ошибку
failSetState func(store.State) error // SetDownloadState вернёт ошибку для перехода
}
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 {
if m.failSetState != nil {
if err := m.failSetState(st); err != nil {
return err
}
}
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) SetDisplayName(_ context.Context, id, name string) error {
if d, ok := m.downloads[id]; ok {
d.DisplayName = name
}
return nil
}
func (m *memStore) SetSourceMissCount(_ context.Context, id string, n int) error {
if d, ok := m.downloads[id]; ok {
d.SourceMissCount = n
}
return nil
}
func (m *memStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error {
if d, ok := m.downloads[id]; ok && !d.SourceAddedAt.Valid {
d.SourceAddedAt = store.NullString(store.FormatTime(t))
}
return nil
}
func (m *memStore) SetRetriedAt(_ context.Context, id string, t time.Time) error {
if d, ok := m.downloads[id]; ok {
d.RetriedAt = store.NullString(store.FormatTime(t))
}
return nil
}
func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (string, error) {
for _, e := range m.recs {
if e.DownloadID == r.DownloadID {
e.IsCurrent = false
}
}
cp := *r
cp.ID = itoa(len(m.recs) + 1)
cp.IsCurrent = true
cp.AttemptNo = 1
for _, e := range m.recs {
if e.DownloadID == r.DownloadID {
cp.AttemptNo++
}
}
b, _ := jsonMarshal(reasons)
cp.Reasons = b
m.recs = append(m.recs, &cp)
return cp.ID, nil
}
func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID string) (*store.Recognition, error) {
for _, e := range m.recs {
if e.DownloadID == downloadID && e.IsCurrent {
cp := *e
return &cp, nil
}
}
return nil, nil
}
func (m *memStore) AddHint(_ context.Context, id string, text string) error {
m.hints[id] = append(m.hints[id], text)
return nil
}
func (m *memStore) ListHints(_ context.Context, id string) ([]string, error) { return m.hints[id], nil }
func (m *memStore) SetOverride(_ context.Context, id string, field, value string) error {
if m.overrides[id] == nil {
m.overrides[id] = map[string]string{}
}
m.overrides[id][field] = value
return nil
}
func (m *memStore) ListOverrides(_ context.Context, id string) (map[string]string, error) {
return m.overrides[id], nil
}
func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) error {
if m.failCreateLinks != nil {
return m.failCreateLinks
}
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) LiveTitleFolders(_ context.Context, provider, providerID, excludeDownloadID string) ([]string, error) {
if provider == "" || providerID == "" {
return nil, nil
}
match := map[string]bool{} // download_id с current recognition этого матча
for _, r := range m.recs {
if r.IsCurrent && r.Provider.String == provider && r.ProviderID.String == providerID {
match[r.DownloadID] = true
}
}
var out []string
for _, l := range m.links {
if l.DownloadID == excludeDownloadID || !match[l.DownloadID] {
continue
}
if isLaidOut(l.Status) {
out = append(out, l.DstPath)
}
}
return out, 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
qb *fakeQbt
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, qb: qb, 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 (путь забран)")
}
}
// --- Правило сходимости папки (проблема второго сезона) ---
// matchCurrentRec помечает current recognition загрузки id подтверждённым
// матчем (для правила сходимости).
func matchCurrentRec(f applyFixture, id, provider, providerID string) {
for _, r := range f.st.recs {
if r.DownloadID == id && r.IsCurrent {
r.Provider = store.NullString(provider)
r.ProviderID = store.NullString(providerID)
}
}
}
// seedLiveAnchor заводит живой якорь тайтла: current recognition матча +
// разложенную ссылку на папку folder под series, и создаёт эту папку на диске.
func seedLiveAnchor(t *testing.T, f applyFixture, dlID, provider, providerID, folder string, onDisk bool) {
t.Helper()
dst := filepath.Join(f.series, folder, "Season 01", folderBaseTest(folder)+" S01E01.mkv")
if onDisk {
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(dst, []byte("anchor"), 0o644); err != nil {
t.Fatal(err)
}
}
f.st.recs = append(f.st.recs, &store.Recognition{
ID: "rec-" + dlID, DownloadID: dlID, IsCurrent: true,
Provider: store.NullString(provider), ProviderID: store.NullString(providerID),
})
f.st.links = append(f.st.links, store.FileLink{
DownloadID: dlID, ApplyBatchID: "anchor-" + dlID, SrcPath: "/anchor/e1.mkv",
DstPath: dst, Kind: "video", Status: "linked",
})
}
// folderBaseTest — «Фарго (2014)» из «Фарго (2014) [tvdbid-269613]» (для имён
// файлов якоря в тесте).
func folderBaseTest(folder string) string {
if i := strings.LastIndex(folder, " ["); i >= 0 {
return folder[:i]
}
return folder
}
func TestApply_FolderConvergence_SecondSeason(t *testing.T) {
// Текущая загрузка распознана как «Show (2006)», но матч tvdb=269613
// совпадает с живым якорем «Фарго (2014)» → наследуем его базу.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
// Второй сезон лёг в папку якоря, база — унаследованная и в имени файла.
want := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 02", "Фарго (2014) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("ожидали раскладку в папку якоря %q: %v", want, err)
}
// Своей папки «Show (2006)» быть не должно.
if _, err := os.Stat(filepath.Join(f.series, "Show (2006) [tvdbid-269613]")); err == nil {
t.Error("создана отдельная папка вместо сходимости")
}
}
func TestApply_FolderConvergence_NoAnchorPrintsFresh(t *testing.T) {
// Матч есть, но живого якоря нет → печатаем базу из распознавания.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("ожидали свежую папку из распознавания %q: %v", want, err)
}
}
func TestApply_FolderConvergence_ProviderMismatchNoInherit(t *testing.T) {
// Якорь заматчен другим провайдером (tmdb) → по (tvdb,269613) не находится,
// базу не наследуем.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tmdb", "60622", "Фарго (2014) [tmdbid-60622]", true)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("смена провайдера не должна склеивать; ожидали %q: %v", want, err)
}
}
func TestApply_FolderConvergence_RenamedAnchorPrintsFresh(t *testing.T) {
// Ссылка якоря живая в БД, но папки на диске нет (переименована) → не якорь.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", false) // onDisk=false
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("отсутствующая на диске папка не якорь; ожидали %q: %v", want, err)
}
}
func TestApply_FolderConvergence_DesyncGoesReview(t *testing.T) {
// Две разные живые папки одного матча → рассинхрон → review, без раскладки.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)
err := f.w.Apply(context.Background(), "1")
if err == nil {
t.Fatal("ожидали ошибку рассинхрона")
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review", f.st.downloads["1"].State)
}
if f.st.downloads["1"].ErrorCode.String != reasonTitleFolderDesync {
t.Errorf("error_code = %q, want %q", f.st.downloads["1"].ErrorCode.String, reasonTitleFolderDesync)
}
// Раскладки не было.
if len(f.st.links) != 2 { // только два якоря
t.Errorf("file_links = %d, раскладка не должна была выполниться", len(f.st.links))
}
}
func TestApply_FolderConvergence_DesyncFromDeferredGoesReview(t *testing.T) {
// Регрессия: apply из deferred при рассинхроне. Уход в review идёт из linking
// (linking→review легален), а не прямым deferred→review (ребра в графе нет).
f := newApplyFixture(t, seriesResult().Plan)
f.st.downloads["1"].State = store.StateDeferred
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)
if err := f.w.Apply(context.Background(), "1"); err == nil {
t.Fatal("ожидали ошибку рассинхрона")
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review", f.st.downloads["1"].State)
}
if f.st.downloads["1"].ErrorCode.String != reasonTitleFolderDesync {
t.Errorf("error_code = %q, want %q", f.st.downloads["1"].ErrorCode.String, reasonTitleFolderDesync)
}
}
func TestReviewData_PreviewMatchesConvergedApply(t *testing.T) {
// Превью активного источника наследует ту же базу, что даст применение.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
rd, err := f.w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
if len(rd.Preview) == 0 {
t.Fatal("превью пусто")
}
// Превью ведёт в папку якоря (относительные пути, с тегом).
if !strings.Contains(rd.Preview[0].Dst, filepath.Join("Фарго (2014) [tvdbid-269613]", "Season 02")) {
t.Errorf("превью = %q, ожидали папку якоря", rd.Preview[0].Dst)
}
}
func TestApply_FolderConvergence_NoMatchNoInherit(t *testing.T) {
// Матча нет (provider=none), но рядом есть живой якорь — наследования быть не
// должно (сходимость только при подтверждённом матче).
f := newApplyFixture(t, seriesResult().Plan) // current rec без provider
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
// Без матча тег пуст, база из распознавания.
want := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
if _, err := os.Stat(want); err != nil {
t.Errorf("без матча наследования нет; ожидали %q: %v", want, err)
}
}
func TestReviewData_PreviewDesyncPrintsFreshNoReview(t *testing.T) {
// Рассинхрон в превью НЕ переводит задачу в review (превью информационно):
// база печатается из распознавания, состояние остаётся review.
f := newApplyFixture(t, seriesResult().Plan)
matchCurrentRec(f, "1", "tvdb", "269613")
seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true)
seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true)
rd, err := f.w.ReviewData(context.Background(), "1")
if err != nil {
t.Fatalf("ReviewData: %v", err)
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, показ превью не должен менять состояние", f.st.downloads["1"].State)
}
if len(rd.Preview) == 0 {
t.Fatal("превью пусто")
}
// База из распознавания (не унаследованная), тег текущего матча.
if !strings.Contains(rd.Preview[0].Dst, filepath.Join("Show (2006) [tvdbid-269613]", "Season 02")) {
t.Errorf("превью при рассинхроне = %q, ожидали свежую базу из распознавания", rd.Preview[0].Dst)
}
}
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 TestDelete_FromDone_RemovesBothSides(t *testing.T) {
f := newApplyFixture(t, seriesResult().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 := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("Delete: %v", err)
}
d := f.st.downloads["1"]
if d.State != store.StateDeleted {
t.Errorf("state = %q, want deleted", d.State)
}
if d.ErrorCode.String != "user_delete" {
t.Errorf("error_code = %q, want user_delete", d.ErrorCode.String)
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Errorf("library 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 len(f.qb.deleted) != 1 || len(f.qb.deleted[0]) == 0 || f.qb.deleted[0][0] != ihTest {
t.Errorf("qbt.Delete calls = %v, want [[%s]]", f.qb.deleted, ihTest)
}
}
func TestDelete_FromOrphaned_RemovesLastCopy(t *testing.T) {
f := newApplyFixture(t, seriesResult().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")
// Источник пропал → библиотечная ссылка стала последней копией (nlink==1):
// Undo бы отказал, Delete снимает осознанно.
for _, src := range []string{"Show/e1.mkv", "Show/e2.mkv"} {
_ = os.Remove(filepath.Join(f.downloads, src))
}
f.qb.torrents = nil // раздачи в qBittorrent больше нет
f.st.downloads["1"].State = store.StateOrphaned
if err := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if f.st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q, want deleted", f.st.downloads["1"].State)
}
if _, err := os.Stat(dst); !os.IsNotExist(err) {
t.Errorf("last-copy link must be removed despite guard: %v", err)
}
// Отсутствие раздачи не ошибка — вызов всё равно состоялся идемпотентно.
if len(f.qb.deleted) != 1 {
t.Errorf("qbt.Delete calls = %d, want 1 (idempotent)", len(f.qb.deleted))
}
}
func TestDelete_FromTargetMissing_Idempotent(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
// Цель удалена вручную, источник на месте.
dst1 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
dst2 := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E02.mkv")
_ = os.Remove(dst1)
_ = os.Remove(dst2)
f.st.downloads["1"].State = store.StateTargetMissing
if err := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("Delete: %v", err)
}
if f.st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q, want deleted", f.st.downloads["1"].State)
}
if len(f.qb.deleted) != 1 { // остаточную раздачу снесли
t.Errorf("qbt.Delete calls = %d, want 1", len(f.qb.deleted))
}
}
func TestDelete_WrongStateRejected(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan) // состояние review
err := f.w.Delete(context.Background(), "1")
if !errors.Is(err, ErrConflict) {
t.Fatalf("Delete from review = %v, want ErrConflict", err)
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review (unchanged)", f.st.downloads["1"].State)
}
if len(f.qb.deleted) != 0 {
t.Errorf("qbt.Delete must not be called, got %v", f.qb.deleted)
}
}
func TestDelete_QbtErrorKeepsState(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
if err := f.w.Apply(context.Background(), "1"); err != nil {
t.Fatalf("Apply: %v", err)
}
f.qb.deleteErr = errors.New("qbittorrent boom")
if err := f.w.Delete(context.Background(), "1"); err == nil {
t.Fatal("Delete must return qbittorrent error")
}
// Ложно в deleted не уходим (место не освобождено).
if f.st.downloads["1"].State == store.StateDeleted {
t.Errorf("state = deleted despite qbt error; want unchanged (done)")
}
// Повторный delete после устранения сбоя идемпотентно дожимает удаление:
// цель уже снята (file_links удалены), источник сносится, задача → deleted.
f.qb.deleteErr = nil
if err := f.w.Delete(context.Background(), "1"); err != nil {
t.Fatalf("repeat Delete: %v", err)
}
if f.st.downloads["1"].State != store.StateDeleted {
t.Errorf("state = %q after repeat, want deleted", f.st.downloads["1"].State)
}
}
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, prov, pid, 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 := providerTag(prov, pid); tag != "tvdbid-269613" {
t.Errorf("tag = %q", tag)
}
}
// refreshSetup: загрузка в review с распознаванием (movie «Догадка»/2000),
// display_name="Unknown", кандидатом cand и раздачей в qBit по ihTest — для
// проверок перелива имени после подтверждения матча.
func refreshSetup(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore, *fakeQbt) {
t.Helper()
st := newMemStore()
d := completedDownload("1")
d.State = store.StateReview
d.DisplayName = "Unknown"
st.put(d)
planJSON, _ := json.Marshal(recognize.Plan{Type: recognize.MediaMovie, 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})
// Ключ раздачи в qBit (Hash) НАМЕРЕННО отличается от нашего инфохэша: matched
// по InfohashV1. Так тест ловит, что rename идёт по реальному t.Hash (мотив
// D3: для гибрид/v2 t.Hash ≠ PrimaryInfohash), а не по сырому инфохэшу.
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: qbtHash, InfohashV1: ihTest, Name: "Unknown"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
return w, st, qb
}
// qbtHash — внутренний ключ раздачи в qBittorrent, отличный от инфохэша загрузки.
const qbtHash = "qbt-internal-key"
func TestChooseCandidate_RefreshesDisplayName(t *testing.T) {
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
const want = "Harold and the Purple Crayon (2024)"
if got := st.downloads["1"].DisplayName; got != want {
t.Errorf("display_name = %q, want %q", got, want)
}
// rename адресован по реальному t.Hash (qbtHash), а НЕ по инфохэшу загрузки.
if len(qb.renamed) != 1 || qb.renamed[0].hash != qbtHash || qb.renamed[0].name != want {
t.Errorf("renamed = %+v, want hash=%s name=%q", qb.renamed, qbtHash, want)
}
if qbtHash == ihTest {
t.Fatal("тест бессмыслен: qbtHash совпал с инфохэшем загрузки")
}
}
func TestChooseCandidate_RefreshDisplayName_QbitErrorTolerated(t *testing.T) {
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
qb.renameErr = errors.New("qbit down")
// Best-effort: сбой переименования раздачи не должен ронять выбор кандидата.
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate must tolerate qbit rename failure: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
t.Errorf("display_name = %q (должен быть обновлён несмотря на сбой qBit)", got)
}
}
func TestChooseCandidate_RefreshDisplayName_TorrentMissing(t *testing.T) {
// Раздача удалена (нет в qBit) — display_name всё равно обновлён, rename нет.
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
qb.torrents = nil // torrentByInfohash не найдёт раздачу → best-effort no-op
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
t.Errorf("display_name = %q (должен быть обновлён и без раздачи)", got)
}
if len(qb.renamed) != 0 {
t.Errorf("renamed = %+v, want none (раздачи нет)", qb.renamed)
}
}
func TestChooseCandidate_RefreshDisplayName_TorrentsLookupError(t *testing.T) {
// Листинг qBit упал — display_name обновлён, rename не зовём, команда цела.
w, st, qb := refreshSetup(t, store.MetadataCandidate{
Provider: "tvdb", ProviderID: "343558",
Title: store.NullString("Harold and the Purple Crayon"), Year: sql.NullInt64{Int64: 2024, Valid: true},
})
qb.torrentsErr = errors.New("qbit down")
if err := w.ChooseCandidate(context.Background(), "1", st.candidates[0].ID); err != nil {
t.Fatalf("ChooseCandidate must tolerate qbit lookup failure: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "Harold and the Purple Crayon (2024)" {
t.Errorf("display_name = %q (должен быть обновлён несмотря на сбой листинга)", got)
}
if len(qb.renamed) != 0 {
t.Errorf("renamed = %+v, want none (листинг упал)", qb.renamed)
}
}
func TestAddManualSource_RefreshesDisplayName(t *testing.T) {
// Ручное добавление источника — тоже подтверждение матча → refresh.
// Кандидат без title/year → имя берётся из распознавания (fallback).
w, st, qb := refreshSetup(t, store.MetadataCandidate{Provider: "tvdb", ProviderID: "269613"})
if err := w.AddManualSource(context.Background(), "1", "tmdb", "603"); err != nil {
t.Fatalf("AddManualSource: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "Догадка (2000)" {
t.Errorf("display_name = %q, want fallback «Догадка (2000)»", got)
}
if len(qb.renamed) != 1 || qb.renamed[0].name != "Догадка (2000)" {
t.Errorf("renamed = %+v", qb.renamed)
}
}
func TestRefreshDisplayName_NoRecognitionNoop(t *testing.T) {
// Нет распознавания — обновлять нечего: display_name не трогаем, rename нет.
st := newMemStore()
d := completedDownload("1")
d.State = store.StateDownloading
d.DisplayName = ""
st.put(d)
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "raw"}}}
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
if err := w.RefreshDisplayName(context.Background(), "1"); err != nil {
t.Fatalf("RefreshDisplayName: %v", err)
}
if got := st.downloads["1"].DisplayName; got != "" {
t.Errorf("display_name = %q, want empty (no-op)", got)
}
if len(qb.renamed) != 0 {
t.Errorf("renamed = %+v, want none", qb.renamed)
}
}
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])
}
// «Без базы» → пустой тег.
_, prov, pid, _ := w.effectivePlan(context.Background(), "1")
if tag := providerTag(prov, pid); 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, prov, pid, 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 := providerTag(prov, pid); 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, prov, pid, 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 := providerTag(prov, pid); 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)
}
})
}
}
// --- Устойчивость раскладки и переходов (MAJOR-4, MINOR-7) ---
// TestApply_ClaimFailureAbortsBeforeLinks (MINOR-7): если запись claim перехода
// в linking падает, Apply ОБЯЗАН прерваться ДО создания хардлинков — иначе
// ссылки лягут при задаче в review, а финальный linking→done граф отклонит.
func TestApply_ClaimFailureAbortsBeforeLinks(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
f.st.failSetState = func(st store.State) error {
if st == store.StateLinking {
return errors.New("boom: claim persist failed")
}
return nil
}
err := f.w.Apply(context.Background(), "1")
if err == nil {
t.Fatal("Apply must fail when linking claim persist fails")
}
if f.st.downloads["1"].State != store.StateReview {
t.Errorf("state = %q, want review (claim not committed)", f.st.downloads["1"].State)
}
if len(f.st.links) != 0 {
t.Errorf("file_links = %d, want 0 (no linking before committed claim)", len(f.st.links))
}
// Хардлинки на диск НЕ созданы — раскладка не запускалась.
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
if _, statErr := os.Stat(dst); statErr == nil {
t.Errorf("hardlink %q created despite failed claim", dst)
}
}
// TestApply_PersistFailureLeavesReview (MAJOR-4 A): хардлинки созданы, но
// CreateFileLinks упал транзиентно → задача не должна застрять в linking; уходит
// в review с причиной, повтор идемпотентен.
func TestApply_PersistFailureLeavesReview(t *testing.T) {
f := newApplyFixture(t, seriesResult().Plan)
f.st.failCreateLinks = errors.New("boom: sqlite busy")
err := f.w.Apply(context.Background(), "1")
if err == nil {
t.Fatal("Apply must fail when persisting links fails")
}
if f.st.downloads["1"].State != store.StateReview {
t.Fatalf("state = %q, want review (not stranded in linking)", f.st.downloads["1"].State)
}
if f.st.downloads["1"].ErrorCode.String != "persist" {
t.Errorf("error_code = %q, want persist", f.st.downloads["1"].ErrorCode.String)
}
// Хардлинки уже на диске (учёт лишь не записан) — повторный Apply их допишет.
dst := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv")
if _, statErr := os.Stat(dst); statErr != nil {
t.Errorf("expected hardlink on disk despite persist failure: %v", statErr)
}
}
// TestSweepLinking_OrphanedToReview (MAJOR-4 B): задача, застрявшая в linking
// после краха, на тике/старте возвращается в review с причиной.
func TestSweepLinking_OrphanedToReview(t *testing.T) {
st := newMemStore()
d := completedDownload("1")
d.State = store.StateLinking
st.put(d)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
w.sweepLinking(context.Background())
got := st.downloads["1"]
if got.State != store.StateReview {
t.Fatalf("state = %q, want review", got.State)
}
if got.ErrorCode.String != "interrupted" {
t.Errorf("error_code = %q, want interrupted", got.ErrorCode.String)
}
if got.ErrorMsg.String == "" {
t.Error("expected error_msg with reason")
}
}
// TestSweepLinking_LeavesOtherStates: sweep трогает только linking, прочие
// состояния (в т.ч. done) не задевает.
func TestSweepLinking_LeavesOtherStates(t *testing.T) {
st := newMemStore()
done := completedDownload("1")
done.State = store.StateDone
st.put(done)
review := completedDownload("2")
review.State = store.StateReview
st.put(review)
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
w.sweepLinking(context.Background())
if st.downloads["1"].State != store.StateDone {
t.Errorf("done task moved to %q", st.downloads["1"].State)
}
if st.downloads["2"].State != store.StateReview {
t.Errorf("review task moved to %q", st.downloads["2"].State)
}
}