Идентичность на ULID: download_infohash, guarded-дедуп, миграция (ulid-identity)
Все сущности переехали с INTEGER AUTOINCREMENT на TEXT ULID (lowercase, internal/ident — единая точка генерации и разбора; oklog/ulid). Инфохэши загрузки — множество (download_infohash, v1/v2 гибридных торрентов): дедуп и сопоставление в поллинге по любому из хешей, magnet-парсер отдаёт оба хеша гибридной ссылки, усечённый v2-хеш v2-only раздач не хранится. Инвариант «не более одной активной загрузки на infohash» вместо снятого unique-индекса держат guarded-методы store в одной write-транзакции (_txlock=immediate): CreateDownloadIfNoActive (приём/adopt, с доносом недостающих хешей), ActivateIfNoOtherActive (retry/recovery/relink, отказ до побочных эффектов), guarded AddInfohashes; SetDownloadState отклоняет терминал→активное как механический бэкстоп. Миграция 0006 — первая Go-миграция goose: пересоздание таблиц при включённых FK, backfill ULID с timestamp из created_at (хронология id сохранена), разнос infohash, удаление idempotency_key. BREAKING: формат id в URL/логах/Telegram, REST-поля id (string) и infohashes (список). Новая конвенция docs/conventions/database.md (без числовых PK), корреляция в логах grep'ом по голому ULID, ER-схема обновлена. Спеки: новая capability identity, MODIFIED в state-reconciliation; change заархивирован. Пройдены ревью дизайна и кода (по 8 углов), все находки исправлены с регрессионными тестами. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+204
-168
@@ -20,12 +20,12 @@ import (
|
||||
|
||||
// recordingNotifier ловит события пинга (Notify асинхронен — через канал).
|
||||
type notifyEvent struct {
|
||||
id int64
|
||||
id string
|
||||
ev NotifyEvent
|
||||
}
|
||||
type recordingNotifier struct{ ch chan notifyEvent }
|
||||
|
||||
func (n *recordingNotifier) Notify(_ context.Context, id int64, ev NotifyEvent) {
|
||||
func (n *recordingNotifier) Notify(_ context.Context, id string, ev NotifyEvent) {
|
||||
n.ch <- notifyEvent{id, ev}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func waitNotify(t *testing.T, n *recordingNotifier) notifyEvent {
|
||||
|
||||
func TestNotifier_FiresOnReview(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
st.put(completedDownload("1"))
|
||||
qb := &fakeQbt{
|
||||
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
|
||||
files: []qbt.File{{Name: "Show/e1.mkv", Size: 1}},
|
||||
@@ -51,10 +51,10 @@ func TestNotifier_FiresOnReview(t *testing.T) {
|
||||
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
||||
w.SetNotifier(n)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
e := waitNotify(t, n)
|
||||
if e.id != 1 || e.ev != EventReview {
|
||||
if e.id != "1" || e.ev != EventReview {
|
||||
t.Errorf("event = %+v, want {1 review}", e)
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,11 @@ func TestNotifier_FiresOnDone(t *testing.T) {
|
||||
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
||||
f.w.SetNotifier(n)
|
||||
|
||||
if err := f.w.Apply(context.Background(), 1); err != nil {
|
||||
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 {
|
||||
if e.id != "1" || e.ev != EventDone {
|
||||
t.Errorf("event = %+v, want {1 done}", e)
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ func TestScanner_FiresOnDone(t *testing.T) {
|
||||
s := &recordingScanner{ch: make(chan struct{}, 4)}
|
||||
f.w.SetScanner(s)
|
||||
|
||||
if err := f.w.Apply(context.Background(), 1); err != nil {
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
select {
|
||||
@@ -97,7 +97,7 @@ func TestScanner_FiresOnDone(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func revertedDownload(id int64) *store.Download {
|
||||
func revertedDownload(id string) *store.Download {
|
||||
d := completedDownload(id)
|
||||
d.State = store.StateReverted
|
||||
return d
|
||||
@@ -105,90 +105,90 @@ func revertedDownload(id int64) *store.Download {
|
||||
|
||||
func TestRelink_RevertedToRecognizing(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(revertedDownload(1))
|
||||
st.put(revertedDownload("1"))
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
|
||||
|
||||
if err := w.Relink(context.Background(), 1); err != nil {
|
||||
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.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])
|
||||
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 := revertedDownload("1")
|
||||
d.State = store.StateCancelled
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{result: seriesResult()}, nil)
|
||||
|
||||
if err := w.Relink(context.Background(), 1); err != nil {
|
||||
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.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])
|
||||
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
|
||||
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 {
|
||||
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 := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Rerecognize(context.Background(), 1); err != nil {
|
||||
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)
|
||||
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
|
||||
st.put(completedDownload("1")) // completed, не review/deferred
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Rerecognize(context.Background(), 1); err == 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))
|
||||
st.put(revertedDownload("1"))
|
||||
qb := &fakeQbt{torrents: nil} // раздачи в qBittorrent нет
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Relink(context.Background(), 1); err == 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)
|
||||
if st.downloads["1"].State != store.StateDeleted {
|
||||
t.Errorf("state = %q, want deleted (preflight привёл к реальности)", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,21 +197,21 @@ func TestRelink_TorrentMissing(t *testing.T) {
|
||||
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")
|
||||
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 {
|
||||
if err := f.w.Relink(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Relink: %v", err)
|
||||
}
|
||||
f.w.recognizeOne(context.Background(), 1)
|
||||
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 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))
|
||||
@@ -220,19 +220,19 @@ func TestRelink_ForceReviewSkipsAuto(t *testing.T) {
|
||||
|
||||
// memStore — полноценный in-memory store для тестов Ф3.
|
||||
type memStore struct {
|
||||
downloads map[int64]*store.Download
|
||||
downloads map[string]*store.Download
|
||||
recs []*store.Recognition
|
||||
hints map[int64][]string
|
||||
overrides map[int64]map[string]string
|
||||
hints map[string][]string
|
||||
overrides map[string]map[string]string
|
||||
links []store.FileLink
|
||||
candidates []store.MetadataCandidate
|
||||
}
|
||||
|
||||
func newMemStore() *memStore {
|
||||
return &memStore{
|
||||
downloads: map[int64]*store.Download{},
|
||||
hints: map[int64][]string{},
|
||||
overrides: map[int64]map[string]string{},
|
||||
downloads: map[string]*store.Download{},
|
||||
hints: map[string][]string{},
|
||||
overrides: map[string]map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,18 +266,18 @@ func (m *memStore) ListRecoverable(_ context.Context, codes ...string) ([]store.
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
|
||||
func (m *memStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) {
|
||||
for _, d := range m.downloads {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash {
|
||||
if hasAnyHash(d, hashes) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *memStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
|
||||
func (m *memStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) {
|
||||
for _, d := range m.downloads {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
|
||||
if hasAnyHash(d, hashes) && !d.State.IsTerminal() {
|
||||
cp := *d
|
||||
return &cp, nil
|
||||
}
|
||||
@@ -285,15 +285,51 @@ func (m *memStore) FindActiveByInfohash(_ context.Context, infohash string) (*st
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *memStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
||||
id := int64(len(m.downloads) + 1)
|
||||
func (m *memStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||
if existing, _ := m.FindActiveByInfohash(ctx, hashes...); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
id := itoa(len(m.downloads) + 1)
|
||||
cp := *d
|
||||
cp.ID = id
|
||||
for _, h := range hashes {
|
||||
h = store.NormalizeHash(h)
|
||||
cp.Infohashes = append(cp.Infohashes, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
||||
}
|
||||
m.downloads[id] = &cp
|
||||
return id, nil
|
||||
d.ID = id
|
||||
d.Infohashes = cp.Infohashes
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
||||
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
|
||||
@@ -302,7 +338,7 @@ func (m *memStore) GetDownload(_ context.Context, id int64) (*store.Download, er
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *memStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
|
||||
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)
|
||||
@@ -310,28 +346,28 @@ func (m *memStore) SetDownloadState(_ context.Context, id int64, st store.State,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) SetSourceMissCount(_ context.Context, id int64, n int) error {
|
||||
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 int64, t time.Time) error {
|
||||
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) (int64, error) {
|
||||
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 = int64(len(m.recs) + 1)
|
||||
cp.ID = itoa(len(m.recs) + 1)
|
||||
cp.IsCurrent = true
|
||||
cp.AttemptNo = 1
|
||||
for _, e := range m.recs {
|
||||
@@ -345,7 +381,7 @@ func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, re
|
||||
return cp.ID, nil
|
||||
}
|
||||
|
||||
func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID int64) (*store.Recognition, error) {
|
||||
func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID string) (*store.Recognition, error) {
|
||||
for _, e := range m.recs {
|
||||
if e.DownloadID == downloadID && e.IsCurrent {
|
||||
cp := *e
|
||||
@@ -355,20 +391,20 @@ func (m *memStore) GetCurrentRecognition(_ context.Context, downloadID int64) (*
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *memStore) AddHint(_ context.Context, id int64, text string) error {
|
||||
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 int64) ([]string, error) { return m.hints[id], nil }
|
||||
func (m *memStore) ListHints(_ context.Context, id string) ([]string, error) { return m.hints[id], nil }
|
||||
|
||||
func (m *memStore) SetOverride(_ context.Context, id int64, field, value string) error {
|
||||
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 int64) (map[string]string, error) {
|
||||
func (m *memStore) ListOverrides(_ context.Context, id string) (map[string]string, error) {
|
||||
return m.overrides[id], nil
|
||||
}
|
||||
|
||||
@@ -376,7 +412,7 @@ func (m *memStore) CreateFileLinks(_ context.Context, links []store.FileLink) er
|
||||
m.links = append(m.links, links...)
|
||||
return nil
|
||||
}
|
||||
func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID int64, dstPaths []string) error {
|
||||
func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID string, dstPaths []string) error {
|
||||
if len(dstPaths) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -395,7 +431,7 @@ func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID int64, ds
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *memStore) LatestBatchID(_ context.Context, id int64) (string, error) {
|
||||
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
|
||||
@@ -425,12 +461,12 @@ func (m *memStore) DeleteFileLinksByBatch(_ context.Context, batch string) error
|
||||
|
||||
func (m *memStore) CreateCandidates(_ context.Context, cands []store.MetadataCandidate) error {
|
||||
for _, c := range cands {
|
||||
c.ID = int64(len(m.candidates) + 1)
|
||||
c.ID = itoa(len(m.candidates) + 1)
|
||||
m.candidates = append(m.candidates, c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID int64) ([]store.MetadataCandidate, error) {
|
||||
func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID string) ([]store.MetadataCandidate, error) {
|
||||
var out []store.MetadataCandidate
|
||||
for _, c := range m.candidates {
|
||||
if c.RecognitionID == recID {
|
||||
@@ -439,7 +475,7 @@ func (m *memStore) ListCandidatesByRecognition(_ context.Context, recID int64) (
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (m *memStore) GetCandidate(_ context.Context, id int64) (*store.MetadataCandidate, error) {
|
||||
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]
|
||||
@@ -448,7 +484,7 @@ func (m *memStore) GetCandidate(_ context.Context, id int64) (*store.MetadataCan
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (m *memStore) SetCandidateChosen(_ context.Context, recID, id int64) error {
|
||||
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
|
||||
@@ -501,10 +537,10 @@ func itoa(n int) string {
|
||||
|
||||
const ihTest = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
|
||||
func completedDownload(id int64) *store.Download {
|
||||
func completedDownload(id string) *store.Download {
|
||||
return &store.Download{
|
||||
ID: id, State: store.StateCompleted, SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:" + ihTest, Infohash: store.NullString(ihTest),
|
||||
SourceRef: "magnet:?xt=urn:btih:" + ihTest, Infohashes: hashesOf(id, ihTest),
|
||||
Context: "ctx",
|
||||
}
|
||||
}
|
||||
@@ -526,7 +562,7 @@ func seriesResult() recognize.Result {
|
||||
|
||||
func TestRecognizeOne_CompletedToReview(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
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}},
|
||||
@@ -534,12 +570,12 @@ func TestRecognizeOne_CompletedToReview(t *testing.T) {
|
||||
rec := &fakeRecognizer{result: seriesResult()}
|
||||
w := testWorkerWith(st, qb, rec, nil)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if st.downloads[1].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review", st.downloads["1"].State)
|
||||
}
|
||||
cur, _ := st.GetCurrentRecognition(context.Background(), 1)
|
||||
cur, _ := st.GetCurrentRecognition(context.Background(), "1")
|
||||
if cur == nil || cur.Title.String != "Show" {
|
||||
t.Fatalf("recognition = %+v", cur)
|
||||
}
|
||||
@@ -554,7 +590,7 @@ func TestRecognizeOne_CompletedToReview(t *testing.T) {
|
||||
// распознавание падало с «torrent not found in qBittorrent».
|
||||
func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
st.put(completedDownload("1"))
|
||||
qb := &fakeQbt{
|
||||
torrents: []qbt.Torrent{{
|
||||
Hash: ihTest, Name: "ThePitt", SavePath: "/d",
|
||||
@@ -565,14 +601,14 @@ func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
|
||||
rec := &fakeRecognizer{result: seriesResult()}
|
||||
w := testWorkerWith(st, qb, rec, nil)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if st.downloads[1].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review", st.downloads[1].State)
|
||||
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)
|
||||
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 {
|
||||
@@ -586,40 +622,40 @@ func TestRecognizeOne_FindsTagAdoptedTorrent(t *testing.T) {
|
||||
|
||||
func TestRecognizeOne_DiscardsWhenStateChanged(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
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
|
||||
st.downloads["1"].State = store.StateCancelled
|
||||
}}
|
||||
w := testWorkerWith(st, qb, rec, nil)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
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 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 {
|
||||
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))
|
||||
st.put(completedDownload("1"))
|
||||
qb := &fakeQbt{torrents: nil} // торрент пропал
|
||||
rec := &fakeRecognizer{result: seriesResult()}
|
||||
w := testWorkerWith(st, qb, rec, nil)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if st.downloads[1].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateReview {
|
||||
t.Fatalf("state = %q, want review", st.downloads["1"].State)
|
||||
}
|
||||
cur, _ := st.GetCurrentRecognition(context.Background(), 1)
|
||||
cur, _ := st.GetCurrentRecognition(context.Background(), "1")
|
||||
if cur == nil || len(cur.ReasonList()) == 0 {
|
||||
t.Fatal("expected review with reason")
|
||||
}
|
||||
@@ -627,82 +663,82 @@ func TestRecognizeOne_SignalsErrorToReview(t *testing.T) {
|
||||
|
||||
func TestRefine_AddsHintAndRerecognizes(t *testing.T) {
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
d := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Refine(context.Background(), 1, "это второй сезон"); err != nil {
|
||||
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 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] != "это второй сезон" {
|
||||
if h := st.hints["1"]; len(h) != 1 || h[0] != "это второй сезон" {
|
||||
t.Errorf("hints = %v", h)
|
||||
}
|
||||
if err := w.Refine(context.Background(), 1, " "); err == nil {
|
||||
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 := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.SetType(context.Background(), 1, "series"); err != nil {
|
||||
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.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 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 {
|
||||
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 := 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 {
|
||||
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 { // повтор не дублирует
|
||||
if err := w.IgnoreFile(context.Background(), "1", "Show/sample.mkv"); err != nil { // повтор не дублирует
|
||||
t.Fatalf("IgnoreFile repeat: %v", err)
|
||||
}
|
||||
ignored := parseIgnored(st.overrides[1][ovrIgnoredFiles])
|
||||
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)
|
||||
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 := completedDownload("1")
|
||||
d.State = store.StateReview
|
||||
st.put(d)
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
|
||||
if err := w.Defer(context.Background(), 1); err != 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)
|
||||
if st.downloads["1"].State != store.StateDeferred {
|
||||
t.Errorf("state = %q, want deferred", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -739,12 +775,12 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
|
||||
}
|
||||
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
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)),
|
||||
ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
|
||||
})
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest, SavePath: downloads, Category: "jellybit"}}}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{}, lay)
|
||||
@@ -755,11 +791,11 @@ func newApplyFixture(t *testing.T, plan recognize.Plan) applyFixture {
|
||||
func TestApply_LinksAndDone(t *testing.T) {
|
||||
f := newApplyFixture(t, seriesResult().Plan)
|
||||
|
||||
if err := f.w.Apply(context.Background(), 1); err != nil {
|
||||
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 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))
|
||||
@@ -780,9 +816,9 @@ func TestApply_IgnoredFileSkipped(t *testing.T) {
|
||||
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"]`)
|
||||
_ = f.st.SetOverride(context.Background(), "1", ovrIgnoredFiles, `["Show/sample.mkv"]`)
|
||||
|
||||
if err := f.w.Apply(context.Background(), 1); err != nil {
|
||||
if err := f.w.Apply(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if len(f.st.links) != 2 { // sample пропущен
|
||||
@@ -798,12 +834,12 @@ func TestApply_CollisionStaysReview(t *testing.T) {
|
||||
_ = os.MkdirAll(filepath.Dir(dst), 0o755)
|
||||
_ = os.WriteFile(dst, []byte("foreign"), 0o644)
|
||||
|
||||
err := f.w.Apply(context.Background(), 1)
|
||||
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)
|
||||
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" {
|
||||
@@ -817,20 +853,20 @@ func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
|
||||
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.put(completedDownload("2"))
|
||||
f.st.links = append(f.st.links, store.FileLink{
|
||||
DownloadID: 2, ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
|
||||
DownloadID: "2", ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
|
||||
Kind: "video", Status: "linked",
|
||||
})
|
||||
|
||||
if err := f.w.Apply(context.Background(), 1); err != nil {
|
||||
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 {
|
||||
if f.st.links[i].DownloadID == "2" {
|
||||
foreign = &f.st.links[i]
|
||||
}
|
||||
}
|
||||
@@ -839,12 +875,12 @@ func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) {
|
||||
}
|
||||
// Свои ссылки (id=1) не тронуты — download_id != self.
|
||||
for _, l := range f.st.links {
|
||||
if l.DownloadID == 1 && !isLaidOut(l.Status) {
|
||||
if l.DownloadID == "1" && !isLaidOut(l.Status) {
|
||||
t.Errorf("своя ссылка %q стала %q, ожидали разложенную", l.DstPath, l.Status)
|
||||
}
|
||||
}
|
||||
// Прежняя загрузка больше не владеет путём → цель отсутствует.
|
||||
present, err := f.w.targetPresent(context.Background(), 2)
|
||||
present, err := f.w.targetPresent(context.Background(), "2")
|
||||
if err != nil {
|
||||
t.Fatalf("targetPresent: %v", err)
|
||||
}
|
||||
@@ -861,17 +897,17 @@ func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
|
||||
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.put(completedDownload("2"))
|
||||
f.st.links = append(f.st.links, store.FileLink{
|
||||
DownloadID: 2, ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
|
||||
DownloadID: "2", ApplyBatchID: "old", SrcPath: "/old/e1.mkv", DstPath: e01,
|
||||
Kind: "video", Status: "linked",
|
||||
})
|
||||
|
||||
if err := f.w.Apply(context.Background(), 1); err == nil {
|
||||
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" {
|
||||
if l.DownloadID == "2" && l.Status != "linked" {
|
||||
t.Errorf("чужая ссылка стала %q при коллизии, владение не должно отбираться", l.Status)
|
||||
}
|
||||
}
|
||||
@@ -880,7 +916,7 @@ func TestApply_CollisionKeepsForeignOwner(t *testing.T) {
|
||||
func TestUndo_RevertsLinks(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
f := newApplyFixture(t, plan)
|
||||
if err := f.w.Apply(context.Background(), 1); err != nil {
|
||||
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")
|
||||
@@ -888,11 +924,11 @@ func TestUndo_RevertsLinks(t *testing.T) {
|
||||
t.Fatalf("precondition: link must exist: %v", err)
|
||||
}
|
||||
|
||||
if err := f.w.Undo(context.Background(), 1); err != nil {
|
||||
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 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)
|
||||
@@ -909,9 +945,9 @@ func TestUndo_RevertsLinks(t *testing.T) {
|
||||
func TestReviewData(t *testing.T) {
|
||||
plan := seriesResult().Plan
|
||||
f := newApplyFixture(t, plan)
|
||||
_ = f.st.AddHint(context.Background(), 1, "подсказка")
|
||||
_ = f.st.AddHint(context.Background(), "1", "подсказка")
|
||||
|
||||
rd, err := f.w.ReviewData(context.Background(), 1)
|
||||
rd, err := f.w.ReviewData(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewData: %v", err)
|
||||
}
|
||||
@@ -964,7 +1000,7 @@ func TestRecognizeOne_AutoApplies(t *testing.T) {
|
||||
lay, _ := layout.New(layout.Config{MoviesDir: movies, SeriesDir: series}, nil)
|
||||
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
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}},
|
||||
@@ -976,10 +1012,10 @@ func TestRecognizeOne_AutoApplies(t *testing.T) {
|
||||
}}
|
||||
w := testWorkerWith(st, qb, rec, lay)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if st.downloads[1].State != store.StateDone {
|
||||
t.Fatalf("state = %q, want done (auto)", st.downloads[1].State)
|
||||
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")
|
||||
@@ -996,7 +1032,7 @@ func TestApply_UsesProviderTag(t *testing.T) {
|
||||
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 {
|
||||
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")
|
||||
@@ -1026,15 +1062,15 @@ func TestProviderTag(t *testing.T) {
|
||||
func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *memStore) {
|
||||
t.Helper()
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
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)),
|
||||
ID: "1", DownloadID: "1", IsCurrent: true, Plan: store.NullString(string(planJSON)),
|
||||
Provider: store.NullString("none"),
|
||||
})
|
||||
cand.RecognitionID = 1
|
||||
cand.RecognitionID = "1"
|
||||
_ = st.CreateCandidates(context.Background(), []store.MetadataCandidate{cand})
|
||||
w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil)
|
||||
return w, st
|
||||
@@ -1042,7 +1078,7 @@ func reviewWithCandidate(t *testing.T, cand store.MetadataCandidate) (*Worker, *
|
||||
|
||||
func TestRecognizeOne_PersistsCandidates(t *testing.T) {
|
||||
st := newMemStore()
|
||||
st.put(completedDownload(1))
|
||||
st.put(completedDownload("1"))
|
||||
qb := &fakeQbt{
|
||||
torrents: []qbt.Torrent{{Hash: ihTest, Name: "Show", SavePath: "/d"}},
|
||||
files: []qbt.File{{Name: "e1.mkv", Size: 1}},
|
||||
@@ -1054,7 +1090,7 @@ func TestRecognizeOne_PersistsCandidates(t *testing.T) {
|
||||
}
|
||||
w := testWorkerWith(st, qb, &fakeRecognizer{result: res}, nil)
|
||||
|
||||
w.recognizeOne(context.Background(), 1)
|
||||
w.recognizeOne(context.Background(), "1")
|
||||
|
||||
if len(st.candidates) != 2 {
|
||||
t.Fatalf("candidates = %d, want 2", len(st.candidates))
|
||||
@@ -1080,10 +1116,10 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
|
||||
})
|
||||
candID := st.candidates[0].ID
|
||||
|
||||
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil {
|
||||
if err := w.ChooseCandidate(context.Background(), "1", candID); err != nil {
|
||||
t.Fatalf("ChooseCandidate: %v", err)
|
||||
}
|
||||
ov := st.overrides[1]
|
||||
ov := st.overrides["1"]
|
||||
if ov[ovrProvider] != "tvdb" || ov[ovrProviderID] != "269613" ||
|
||||
ov[ovrTitle] != "Fargo" || ov[ovrYear] != "2014" {
|
||||
t.Errorf("overrides = %v", ov)
|
||||
@@ -1092,7 +1128,7 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
|
||||
t.Error("кандидат не помечен выбранным")
|
||||
}
|
||||
// Эффективный план берёт каноническое имя/год и тег [tvdbid-...].
|
||||
plan, tag, err := w.effectivePlan(context.Background(), 1)
|
||||
plan, tag, err := w.effectivePlan(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("effectivePlan: %v", err)
|
||||
}
|
||||
@@ -1106,38 +1142,38 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) {
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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 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 {
|
||||
if err := w.SetProviderID(context.Background(), "1", "kinopoisk", "1"); err == nil {
|
||||
t.Error("недопустимый провайдер должен отклоняться")
|
||||
}
|
||||
if err := w.SetProviderID(context.Background(), 1, "tmdb", ""); err == nil {
|
||||
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 {
|
||||
_ = 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])
|
||||
if st.overrides["1"][ovrProvider] != "none" {
|
||||
t.Errorf("provider override = %q, want none", st.overrides["1"][ovrProvider])
|
||||
}
|
||||
// «Без базы» → пустой тег.
|
||||
_, tag, _ := w.effectivePlan(context.Background(), 1)
|
||||
_, tag, _ := w.effectivePlan(context.Background(), "1")
|
||||
if tag != "" {
|
||||
t.Errorf("tag = %q, want empty", tag)
|
||||
}
|
||||
@@ -1148,10 +1184,10 @@ func TestReviewData_IncludesCandidates(t *testing.T) {
|
||||
Provider: "tvdb", ProviderID: "269613", Title: store.NullString("Fargo"),
|
||||
})
|
||||
candID := st.candidates[0].ID
|
||||
if err := w.ChooseCandidate(context.Background(), 1, candID); err != nil {
|
||||
if err := w.ChooseCandidate(context.Background(), "1", candID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rd, err := w.ReviewData(context.Background(), 1)
|
||||
rd, err := w.ReviewData(context.Background(), "1")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewData: %v", err)
|
||||
}
|
||||
@@ -1169,7 +1205,7 @@ func TestReviewData_IncludesCandidates(t *testing.T) {
|
||||
func TestToStoreCandidates_URL(t *testing.T) {
|
||||
// Кандидат с URL: URL должен быть проброшен как непустой NullString.
|
||||
// Кандидат без URL: URL должен быть пустым NullString (Valid=false → NULL).
|
||||
candURL := toStoreCandidates(1, []metadata.Candidate{
|
||||
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: ""},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user