Идентичность на 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:
av
2026-07-02 21:25:00 +03:00
co-authored by Claude Fable 5
parent b808ceff25
commit 37f2f6481a
53 changed files with 3640 additions and 1035 deletions
+204 -168
View File
@@ -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: ""},
})