Идентичность на 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:
+49
-31
@@ -2,6 +2,7 @@ package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -10,13 +11,13 @@ import (
|
||||
)
|
||||
|
||||
// discover усыновляет новые раздачи: для каждого торрента с нашей категорией
|
||||
// ИЛИ тегом, чьего infohash ещё нет в БД, заводит задачу downloading. Дальше
|
||||
// ИЛИ тегом, чьих хешей ещё нет в БД, заводит задачу downloading. Дальше
|
||||
// её ведёт обычный reconcile. Вызывается под w.mu.
|
||||
//
|
||||
// Корректность при гонке с Ingest (другая горутина): Ingest пишет строку в
|
||||
// БД до добавления в qBit и ставит idempotency_key=infohash, на который есть
|
||||
// UNIQUE-индекс. Поэтому даже если тик и Ingest столкнутся в окне «проверил →
|
||||
// вставляю», второй INSERT упадёт на индексе, и adopt просто пропустит.
|
||||
// Корректность при гонке с Ingest (другая горутина): и adopt, и приём идут
|
||||
// через store.CreateDownloadIfNoActive — атомарный check-then-insert в одной
|
||||
// write-транзакции; при столкновении второй участник получает существующую
|
||||
// активную задачу и просто пропускает.
|
||||
func (w *Worker) discover(ctx context.Context, torrents []qbt.Torrent) {
|
||||
for _, t := range torrents {
|
||||
if w.tracked(t) {
|
||||
@@ -35,13 +36,13 @@ func (w *Worker) tracked(t qbt.Torrent) bool {
|
||||
|
||||
// adopt заводит задачу под торрент, если его ещё не видели.
|
||||
func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
||||
infohash := firstInfohash(t)
|
||||
if infohash == "" {
|
||||
hashes := torrentHashes(t)
|
||||
if len(hashes) == 0 {
|
||||
return // нечем идентифицировать (напр. ещё metaDL без хэша)
|
||||
}
|
||||
exists, err := w.store.ExistsByInfohash(ctx, infohash)
|
||||
exists, err := w.store.ExistsByInfohash(ctx, hashes...)
|
||||
if err != nil {
|
||||
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", infohash, "error", err)
|
||||
w.log.Warn("discover exists check failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
@@ -49,33 +50,29 @@ func (w *Worker) adopt(ctx context.Context, t qbt.Torrent) {
|
||||
}
|
||||
|
||||
d := &store.Download{
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
||||
Infohash: store.NullString(infohash),
|
||||
IdempotencyKey: store.NullString(infohash),
|
||||
State: store.StateDownloading,
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: magnetURN(hashes[0]),
|
||||
DisplayName: t.Name, // усыновление: приёма/rename нет, берём имя торрента из qBittorrent
|
||||
State: store.StateDownloading,
|
||||
}
|
||||
id, err := w.store.CreateDownload(ctx, d)
|
||||
existing, err := w.store.CreateDownloadIfNoActive(ctx, d, hashes)
|
||||
if err != nil {
|
||||
// Гонка: Ingest/другой тик мог вставить запись между проверкой и
|
||||
// вставкой — UNIQUE-индекс это отсёк. Если запись появилась, всё ок.
|
||||
if ex, _ := w.store.ExistsByInfohash(ctx, infohash); ex {
|
||||
return
|
||||
}
|
||||
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", infohash, "error", err)
|
||||
w.log.Error("discover adopt failed", "capability", capIngest, "infohash", hashes[0], "error", err)
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
return // гонка с Ingest/другим тиком: задача уже заведена — всё ок
|
||||
}
|
||||
// Базис сортировки — время добавления в источник; у усыновлённого оно уже
|
||||
// известно (created_at задачи было бы моментом усыновления, не добавления).
|
||||
if t.AddedOn > 0 {
|
||||
if err := w.store.SetSourceAddedAt(ctx, id, time.Unix(t.AddedOn, 0)); err != nil {
|
||||
if err := w.store.SetSourceAddedAt(ctx, d.ID, time.Unix(t.AddedOn, 0)); err != nil {
|
||||
w.log.Warn("adopt set source_added_at failed",
|
||||
"capability", capIngest, "download_id", id, "error", err)
|
||||
"capability", capIngest, "download_id", d.ID, "error", err)
|
||||
}
|
||||
}
|
||||
w.log.Info("discover adopted torrent",
|
||||
"capability", capIngest, "download_id", id, "infohash", infohash, "name", t.Name,
|
||||
"capability", capIngest, "download_id", d.ID, "infohash", hashes[0], "name", t.Name,
|
||||
"category", t.Category, "tags", t.Tags)
|
||||
}
|
||||
|
||||
@@ -92,12 +89,33 @@ func hasTag(tags, tag string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// firstInfohash возвращает первый непустой infohash торрента (нижний регистр).
|
||||
func firstInfohash(t qbt.Torrent) string {
|
||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||
if h != "" {
|
||||
return strings.ToLower(h)
|
||||
// torrentHashes — все непустые хеши торрента (нижний регистр, без дублей,
|
||||
// v1-приоритетный порядок: v1 раньше v2). Единственный сборщик хешей
|
||||
// торрента для записи в БД: t.Hash берётся только когда qBittorrent не
|
||||
// отдал infohash_v1/v2 (старые версии API); у v2-only раздачи t.Hash — это
|
||||
// УСЕЧЁННЫЙ до 40 hex v2-хеш, хранить его нельзя (по длине он неотличим от
|
||||
// v1 и порождает битые btih-magnet при retry).
|
||||
func torrentHashes(t qbt.Torrent) []string {
|
||||
cands := []string{t.InfohashV1, t.InfohashV2}
|
||||
if t.InfohashV1 == "" && t.InfohashV2 == "" {
|
||||
cands = append(cands, t.Hash)
|
||||
}
|
||||
var out []string
|
||||
for _, h := range cands {
|
||||
h = store.NormalizeHash(h)
|
||||
if h != "" && !slices.Contains(out, h) {
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
return out
|
||||
}
|
||||
|
||||
// magnetURN — синтетический источник усыновлённой раздачи по её хешу:
|
||||
// btih для v1, btmh (multihash sha256, префикс 1220) для v2. Хеш обязан
|
||||
// быть полноразмерным (torrentHashes усечённые не отдаёт).
|
||||
func magnetURN(h string) string {
|
||||
if store.HashKind(h) == store.HashV2 {
|
||||
return "magnet:?xt=urn:btmh:1220" + h
|
||||
}
|
||||
return "magnet:?xt=urn:btih:" + h
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
const ihDisc = "7931aa3ed6666746012f5739d099b5bc64d72a16"
|
||||
|
||||
func emptyStore() *fakeStore {
|
||||
return &fakeStore{downloads: map[int64]*store.Download{}}
|
||||
return &fakeStore{downloads: map[string]*store.Download{}}
|
||||
}
|
||||
|
||||
// findByInfohash возвращает усыновлённую задачу по infohash.
|
||||
func findByInfohash(st *fakeStore, infohash string) *store.Download {
|
||||
for _, d := range st.downloads {
|
||||
if d.Infohash.String == infohash {
|
||||
if hasAnyHash(d, []string{infohash}) {
|
||||
return d
|
||||
}
|
||||
}
|
||||
@@ -38,8 +38,8 @@ func TestDiscover_AdoptsByCategory(t *testing.T) {
|
||||
if d.State != store.StateDownloading || d.SourceType != store.SourceMagnet {
|
||||
t.Errorf("adopted = %+v", d)
|
||||
}
|
||||
if d.IdempotencyKey.String != ihDisc {
|
||||
t.Errorf("idempotency_key = %q", d.IdempotencyKey.String)
|
||||
if len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV1 {
|
||||
t.Errorf("infohashes = %+v", d.Infohashes)
|
||||
}
|
||||
// Усыновление берёт заголовок из имени торрента qBittorrent и фиксирует
|
||||
// время добавления (added_on) как базис сортировки.
|
||||
@@ -79,8 +79,8 @@ func TestDiscover_SkipsUntracked(t *testing.T) {
|
||||
func TestDiscover_SkipsExisting(t *testing.T) {
|
||||
st := emptyStore()
|
||||
// Уже есть задача (напр. терминальная done) — не переусыновляем.
|
||||
st.downloads[1] = &store.Download{
|
||||
ID: 1, State: store.StateDone, Infohash: store.NullString(ihDisc),
|
||||
st.downloads["1"] = &store.Download{
|
||||
ID: "1", State: store.StateDone, Infohashes: hashesOf("1", ihDisc),
|
||||
}
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
w.discover(context.Background(), []qbt.Torrent{
|
||||
@@ -104,9 +104,9 @@ func TestDiscover_SkipsNoInfohash(t *testing.T) {
|
||||
// уже скачанная раздача за один тик усыновляется и доходит до completed.
|
||||
func TestPoll_CapturesSourceAddedAt(t *testing.T) {
|
||||
ih := "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
|
||||
st := &fakeStore{downloads: map[int64]*store.Download{
|
||||
1: {ID: 1, State: store.StateDownloading, SourceType: store.SourceMagnet,
|
||||
Infohash: store.NullString(ih), IdempotencyKey: store.NullString(ih)},
|
||||
st := &fakeStore{downloads: map[string]*store.Download{
|
||||
"1": {ID: "1", State: store.StateDownloading, SourceType: store.SourceMagnet,
|
||||
Infohashes: hashesOf("1", ih)},
|
||||
}}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{
|
||||
{Hash: ih, Name: "X", Category: "jellybit", State: "downloading", AddedOn: 1_700_000_000},
|
||||
@@ -116,7 +116,7 @@ func TestPoll_CapturesSourceAddedAt(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if d := st.downloads[1]; !d.SourceAddedAt.Valid {
|
||||
if d := st.downloads["1"]; !d.SourceAddedAt.Valid {
|
||||
t.Fatalf("source_added_at не захвачен при поллинге активной задачи")
|
||||
}
|
||||
}
|
||||
@@ -160,14 +160,60 @@ func TestHasTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstInfohash(t *testing.T) {
|
||||
if got := firstInfohash(qbt.Torrent{Hash: "ABC"}); got != "abc" {
|
||||
t.Errorf("got %q", got)
|
||||
func TestTorrentHashes(t *testing.T) {
|
||||
got := torrentHashes(qbt.Torrent{Hash: "ABC", InfohashV1: "abc", InfohashV2: "DEF"})
|
||||
if len(got) != 2 || got[0] != "abc" || got[1] != "def" {
|
||||
t.Errorf("got %v, want [abc def] (lowercase, без дублей, v1 первым)", got)
|
||||
}
|
||||
if got := firstInfohash(qbt.Torrent{InfohashV2: "DEF"}); got != "def" {
|
||||
t.Errorf("got %q", got)
|
||||
if got := torrentHashes(qbt.Torrent{}); len(got) != 0 {
|
||||
t.Errorf("got %v, want empty", got)
|
||||
}
|
||||
if got := firstInfohash(qbt.Torrent{}); got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
// Старый qBittorrent без infohash_v1/v2 — берём hash.
|
||||
if got := torrentHashes(qbt.Torrent{Hash: "ABC"}); len(got) != 1 || got[0] != "abc" {
|
||||
t.Errorf("legacy hash: got %v, want [abc]", got)
|
||||
}
|
||||
// v2-only: t.Hash — УСЕЧЁННЫЙ v2 (40 hex), хранить его нельзя.
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
got = torrentHashes(qbt.Torrent{Hash: v2[:40], InfohashV2: v2})
|
||||
if len(got) != 1 || got[0] != v2 {
|
||||
t.Errorf("v2-only: got %v, want только полный v2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Усыновление v2-only раздачи: SourceRef — валидный btmh-magnet из полного
|
||||
// v2-хеша (не битый btih из усечённого), kind в БД — v2.
|
||||
func TestDiscover_AdoptsV2Only(t *testing.T) {
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
st := emptyStore()
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
w.discover(context.Background(), []qbt.Torrent{
|
||||
{Hash: v2[:40], InfohashV2: v2, Name: "V2Only", Category: "jellybit", State: "downloading"},
|
||||
})
|
||||
d := findByInfohash(st, v2)
|
||||
if d == nil {
|
||||
t.Fatal("v2-only раздача не усыновлена")
|
||||
}
|
||||
if d.SourceRef != "magnet:?xt=urn:btmh:1220"+v2 {
|
||||
t.Errorf("SourceRef = %q, want btmh с полным v2", d.SourceRef)
|
||||
}
|
||||
if len(d.Infohashes) != 1 || d.Infohashes[0].Kind != store.HashV2 {
|
||||
t.Errorf("infohashes = %+v, want один v2 (усечённый не хранится)", d.Infohashes)
|
||||
}
|
||||
}
|
||||
|
||||
// Усыновление гибридного торрента записывает оба хеша.
|
||||
func TestDiscover_AdoptsBothHashes(t *testing.T) {
|
||||
const v2 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
st := emptyStore()
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
w.discover(context.Background(), []qbt.Torrent{
|
||||
{Hash: ihDisc, InfohashV1: ihDisc, InfohashV2: v2, Name: "Hybrid", Category: "jellybit", State: "downloading"},
|
||||
})
|
||||
d := findByInfohash(st, v2)
|
||||
if d == nil {
|
||||
t.Fatal("гибридная раздача не находится по v2-хешу")
|
||||
}
|
||||
if len(d.Infohashes) != 2 {
|
||||
t.Errorf("infohashes = %+v, want v1+v2", d.Infohashes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestPollBuildsLiveSnapshot(t *testing.T) {
|
||||
Ratio: 2.5, NumSeeds: 3, NumLeechs: 1, Uploaded: 999, Upspeed: 50,
|
||||
},
|
||||
}}
|
||||
w := newTestWorker(&fakeStore{downloads: map[int64]*store.Download{}}, qb)
|
||||
w := newTestWorker(&fakeStore{downloads: map[string]*store.Download{}}, qb)
|
||||
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
@@ -57,11 +56,11 @@ func (w *Worker) reconcileDesync(ctx context.Context, byHash map[string]qbt.Torr
|
||||
// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с
|
||||
// дебаунсом) и цели, выводит состояние и переходит при изменении.
|
||||
func (w *Worker) reconcileOneDesync(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
||||
if !d.Infohash.Valid {
|
||||
if len(d.Infohashes) == 0 {
|
||||
return // нечем сопоставить источник
|
||||
}
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
||||
_, sourceSeen := byHash[strings.ToLower(d.Infohash.String)]
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||
_, sourceSeen := torrentFor(d, byHash)
|
||||
|
||||
// Дебаунс пропажи источника: считаем удалённым только после порога подряд
|
||||
// идущих промахов; любое появление сбрасывает счётчик.
|
||||
@@ -104,7 +103,7 @@ func (w *Worker) debounceSource(ctx context.Context, d store.Download, sourceSee
|
||||
// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель
|
||||
// считается присутствующей, только если существуют ВСЕ ссылки последнего
|
||||
// батча; частичная пропажа — это отсутствие цели (библиотека сломана → relink).
|
||||
func (w *Worker) targetPresent(ctx context.Context, id int64) (bool, error) {
|
||||
func (w *Worker) targetPresent(ctx context.Context, id string) (bool, error) {
|
||||
batch, err := w.store.LatestBatchID(ctx, id)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("latest batch: %w", err)
|
||||
@@ -180,10 +179,10 @@ func (w *Worker) reconcileRecovery(ctx context.Context, byHash map[string]qbt.To
|
||||
// reconcileOneRecovery возвращает одну зависшую задачу в поток, если её торрент
|
||||
// присутствует и продвинулся за условие падения.
|
||||
func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) {
|
||||
if !d.Infohash.Valid {
|
||||
if len(d.Infohashes) == 0 {
|
||||
return
|
||||
}
|
||||
t, ok := byHash[strings.ToLower(d.Infohash.String)]
|
||||
t, ok := torrentFor(d, byHash)
|
||||
if !ok {
|
||||
return // источника нет — оставляем как есть (вернёт ручной retry)
|
||||
}
|
||||
@@ -194,28 +193,22 @@ func (w *Worker) reconcileOneRecovery(ctx context.Context, d store.Download, byH
|
||||
if want == "" {
|
||||
return // переходное состояние qBit (moving/checking) — ждём
|
||||
}
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||
|
||||
// Конфликт идемпотентности: пока задача лежала в failed, тот же infohash мог
|
||||
// взять другая активная задача (idempotency_key снят при падении). Оба
|
||||
// целевых состояния (downloading/completed) нетерминальны → SetDownloadState
|
||||
// восстановит idempotency_key = infohash; при занятом ключе упёрлись бы в
|
||||
// unique-индекс. Поэтому проверяем владельца независимо от целевого состояния
|
||||
// и оставляем старую задачу в failed.
|
||||
other, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
|
||||
if err != nil {
|
||||
logctx.From(ctx).Warn("recovery active lookup failed", "error", err)
|
||||
return
|
||||
}
|
||||
if other != nil && other.ID != d.ID {
|
||||
logctx.From(ctx).Info("recovery skipped, infohash taken by active download",
|
||||
"conflict_download_id", other.ID)
|
||||
return
|
||||
}
|
||||
// Возврат в активное состояние — только через атомарный гард инварианта:
|
||||
// пока задача лежала в failed, тем же infohash могла завладеть другая
|
||||
// активная задача (новый приём). Тогда старую оставляем в failed.
|
||||
// error_code/error_msg не пишем — задача снова здорова; причину в лог, а не в
|
||||
// поле ошибки (иначе она светилась бы в UI/REST как ошибка живой задачи).
|
||||
logctx.From(ctx).Info("recovery from failure", "to", want, "qbit_state", t.State)
|
||||
w.transition(ctx, d, want, "", "")
|
||||
if err := w.store.ActivateIfNoOtherActive(ctx, d.ID, want, "", ""); err != nil {
|
||||
if errors.Is(err, store.ErrInfohashTaken) {
|
||||
logctx.From(ctx).Info("recovery skipped, infohash taken by active download", "error", err)
|
||||
return
|
||||
}
|
||||
logctx.From(ctx).Warn("recovery activate failed", "error", err)
|
||||
return
|
||||
}
|
||||
logctx.From(ctx).Info("recovery from failure", "from", d.State, "to", want, "qbit_state", t.State)
|
||||
}
|
||||
|
||||
// torrentProgressed сообщает, продвинулся ли торрент за условие, по которому
|
||||
@@ -256,10 +249,10 @@ func recoveredState(state string) store.State {
|
||||
// qBittorrent прямо сейчас. При отсутствии приводит состояние к реальности и
|
||||
// возвращает ErrConflict. Недоступность qBittorrent — честный отказ операции.
|
||||
func (w *Worker) ensureSourcePresent(ctx context.Context, d *store.Download, op string) error {
|
||||
if !d.Infohash.Valid {
|
||||
return fmt.Errorf("%s: download %d has no infohash", op, d.ID)
|
||||
if len(d.Infohashes) == 0 {
|
||||
return fmt.Errorf("%s: download %s has no infohash", op, d.ID)
|
||||
}
|
||||
_, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||
_, ok, err := w.torrentByInfohash(ctx, d.HashList())
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", op, err)
|
||||
}
|
||||
|
||||
@@ -31,11 +31,11 @@ func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTar
|
||||
}
|
||||
|
||||
st := newMemStore()
|
||||
d := completedDownload(1)
|
||||
d := completedDownload("1")
|
||||
d.State = state
|
||||
st.put(d)
|
||||
st.links = append(st.links, store.FileLink{
|
||||
DownloadID: 1, ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
|
||||
DownloadID: "1", ApplyBatchID: "b1", SrcPath: src, DstPath: dst,
|
||||
Kind: "video", Status: "linked",
|
||||
})
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestReconcileMatrix(t *testing.T) {
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != tc.want {
|
||||
if got := f.st.downloads["1"].State; got != tc.want {
|
||||
t.Errorf("state = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
@@ -78,7 +78,7 @@ func TestReconcileHealing(t *testing.T) {
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateDone {
|
||||
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||
t.Errorf("state = %q, want done (healing)", got)
|
||||
}
|
||||
}
|
||||
@@ -88,13 +88,13 @@ func TestReconcilePartialTargetLoss(t *testing.T) {
|
||||
f := newReconcileFixture(t, store.StateDone, true, true)
|
||||
missing := filepath.Join(filepath.Dir(f.dst), "Movie (2024).en.srt")
|
||||
f.st.links = append(f.st.links, store.FileLink{
|
||||
DownloadID: 1, ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
|
||||
DownloadID: "1", ApplyBatchID: "b1", SrcPath: "/x.srt", DstPath: missing,
|
||||
Kind: "subtitle", Status: "linked",
|
||||
})
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateTargetMissing {
|
||||
if got := f.st.downloads["1"].State; got != store.StateTargetMissing {
|
||||
t.Errorf("state = %q, want target_missing (частичная пропажа)", got)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func TestReconcileSkipsDeleted(t *testing.T) {
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateDeleted {
|
||||
if got := f.st.downloads["1"].State; got != store.StateDeleted {
|
||||
t.Errorf("state = %q, want deleted (сверка не трогает терминальное)", got)
|
||||
}
|
||||
}
|
||||
@@ -121,17 +121,17 @@ func TestReconcileDebounce(t *testing.T) {
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll %d: %v", i, err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateDone {
|
||||
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||
t.Fatalf("tick %d: state = %q, want done (до порога)", i, got)
|
||||
}
|
||||
if got := f.st.downloads[1].SourceMissCount; got != i {
|
||||
if got := f.st.downloads["1"].SourceMissCount; got != i {
|
||||
t.Errorf("tick %d: miss = %d, want %d", i, got, i)
|
||||
}
|
||||
}
|
||||
if err := f.w.Poll(context.Background()); err != nil { // третий промах
|
||||
t.Fatalf("Poll 3: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateOrphaned {
|
||||
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
|
||||
t.Fatalf("tick 3: state = %q, want orphaned (порог достигнут)", got)
|
||||
}
|
||||
|
||||
@@ -140,10 +140,10 @@ func TestReconcileDebounce(t *testing.T) {
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll heal: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateDone {
|
||||
if got := f.st.downloads["1"].State; got != store.StateDone {
|
||||
t.Errorf("state = %q, want done (источник вернулся)", got)
|
||||
}
|
||||
if got := f.st.downloads[1].SourceMissCount; got != 0 {
|
||||
if got := f.st.downloads["1"].SourceMissCount; got != 0 {
|
||||
t.Errorf("miss = %d, want 0 (сброс)", got)
|
||||
}
|
||||
}
|
||||
@@ -154,18 +154,18 @@ func TestReconcileSkipsActiveStates(t *testing.T) {
|
||||
if err := f.w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateDownloading {
|
||||
if got := f.st.downloads["1"].State; got != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading (сверка не трогает активные)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndoRejectedForOrphaned(t *testing.T) {
|
||||
f := newReconcileFixture(t, store.StateOrphaned, false, true)
|
||||
err := f.w.Undo(context.Background(), 1)
|
||||
err := f.w.Undo(context.Background(), "1")
|
||||
if err == nil {
|
||||
t.Fatal("ожидали отказ Undo для orphaned")
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateOrphaned {
|
||||
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
|
||||
t.Errorf("state = %q, want orphaned (без изменений)", got)
|
||||
}
|
||||
}
|
||||
@@ -173,14 +173,14 @@ func TestUndoRejectedForOrphaned(t *testing.T) {
|
||||
func TestRelinkFromTargetMissing(t *testing.T) {
|
||||
// target_missing + источник на месте → relink ведёт в recognizing.
|
||||
f := newReconcileFixture(t, store.StateTargetMissing, true, false)
|
||||
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)
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateRecognizing {
|
||||
if got := f.st.downloads["1"].State; got != store.StateRecognizing {
|
||||
t.Errorf("state = %q, want recognizing", got)
|
||||
}
|
||||
if f.st.overrides[1][ovrForceReview] != "1" {
|
||||
t.Errorf("force_review = %q, want 1", f.st.overrides[1][ovrForceReview])
|
||||
if f.st.overrides["1"][ovrForceReview] != "1" {
|
||||
t.Errorf("force_review = %q, want 1", f.st.overrides["1"][ovrForceReview])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,10 +189,10 @@ func TestPreflightFixesStaleState(t *testing.T) {
|
||||
// а цель на месте: relink немедленно приводит состояние к orphaned, не
|
||||
// дожидаясь фоновой сверки.
|
||||
f := newReconcileFixture(t, store.StateTargetMissing, false, true)
|
||||
if err := f.w.Relink(context.Background(), 1); err == nil {
|
||||
if err := f.w.Relink(context.Background(), "1"); err == nil {
|
||||
t.Fatal("ожидали отказ relink при пропавшем источнике")
|
||||
}
|
||||
if got := f.st.downloads[1].State; got != store.StateOrphaned {
|
||||
if got := f.st.downloads["1"].State; got != store.StateOrphaned {
|
||||
t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ import (
|
||||
var addedRecent = time.Date(2026, 6, 14, 9, 59, 0, 0, time.UTC).Unix()
|
||||
|
||||
func oneFailed(state store.State, code, infohash, createdAt string) *fakeStore {
|
||||
return &fakeStore{downloads: map[int64]*store.Download{
|
||||
1: {
|
||||
ID: 1,
|
||||
return &fakeStore{downloads: map[string]*store.Download{
|
||||
"1": {
|
||||
ID: "1",
|
||||
State: state,
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||
Infohash: store.NullString(infohash),
|
||||
Infohashes: hashesOf("1", infohash),
|
||||
ErrorCode: store.NullString(code),
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
@@ -52,7 +52,7 @@ func TestRecovery(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := st.downloads[1].State; got != tc.want {
|
||||
if got := st.downloads["1"].State; got != tc.want {
|
||||
t.Errorf("state = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
@@ -68,8 +68,8 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateFailed {
|
||||
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("без источника задача должна остаться failed, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,11 +78,11 @@ func TestRecoveryNoSourceStaysFailed(t *testing.T) {
|
||||
func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
||||
st.downloads[2] = &store.Download{
|
||||
ID: 2,
|
||||
st.downloads["2"] = &store.Download{
|
||||
ID: "2",
|
||||
State: store.StateDownloading,
|
||||
SourceType: store.SourceMagnet,
|
||||
Infohash: store.NullString(ih),
|
||||
Infohashes: hashesOf("2", ih),
|
||||
CreatedAt: timeRecent,
|
||||
}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "downloading", AddedOn: addedRecent}}}
|
||||
@@ -90,8 +90,8 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateFailed {
|
||||
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("при конфликте ключа задача #1 должна остаться failed, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,11 +101,11 @@ func TestRecoverySkipsOnIdempotencyConflict(t *testing.T) {
|
||||
func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneFailed(store.StateFailed, errCodeMagnetTimeout, ih, timeOld)
|
||||
st.downloads[2] = &store.Download{
|
||||
ID: 2,
|
||||
st.downloads["2"] = &store.Download{
|
||||
ID: "2",
|
||||
State: store.StateDownloading,
|
||||
SourceType: store.SourceMagnet,
|
||||
Infohash: store.NullString(ih),
|
||||
Infohashes: hashesOf("2", ih),
|
||||
CreatedAt: timeRecent,
|
||||
}
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "uploading", AddedOn: addedRecent}}}
|
||||
@@ -113,8 +113,8 @@ func TestRecoverySkipsConflictOnCompleted(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateFailed {
|
||||
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("при конфликте ключа задача #1 не должна уходить в completed, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ func TestFailNotifyDebounce(t *testing.T) {
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
n := &recordingNotifier{ch: make(chan notifyEvent, 4)}
|
||||
w.SetNotifier(n)
|
||||
d := *st.downloads[1]
|
||||
d := *st.downloads["1"]
|
||||
|
||||
w.transition(context.Background(), d, store.StateStuck, errCodeStalled, "")
|
||||
if e := waitNotify(t, n); e.ev != EventFailed {
|
||||
@@ -151,11 +151,11 @@ func TestRetryReattachesNoReadd(t *testing.T) {
|
||||
qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ih, State: "metaDL", AddedOn: addedRecent}}}
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), 1); err != nil {
|
||||
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Retry: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateDownloading {
|
||||
t.Fatalf("после retry ожидался downloading, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Fatalf("после retry ожидался downloading, got %q", st.downloads["1"].State)
|
||||
}
|
||||
if len(qb.added) != 0 {
|
||||
t.Errorf("живой торрент не должен добавляться повторно, got %d Add", len(qb.added))
|
||||
@@ -164,7 +164,7 @@ func TestRetryReattachesNoReadd(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateDownloading {
|
||||
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("свежий metaDL не должен падать после retry, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
+64
-60
@@ -50,7 +50,7 @@ func (w *Worker) recognizePending(ctx context.Context) {
|
||||
// под блокировкой переводим в recognizing, LLM зовём без блокировки, затем
|
||||
// под блокировкой фиксируем результат — но только если задачу за это время
|
||||
// не увели в другое состояние (cancel/defer).
|
||||
func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
||||
func (w *Worker) recognizeOne(ctx context.Context, id string) {
|
||||
w.mu.Lock()
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
@@ -62,7 +62,7 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx = w.scoped(ctx, capRecognize, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capRecognize, id, d.PrimaryInfohash())
|
||||
if d.State == store.StateCompleted {
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
}
|
||||
@@ -84,10 +84,10 @@ func (w *Worker) recognizeOne(ctx context.Context, id int64) {
|
||||
// затем зовёт распознаватель. Возвращает также savePath для маппинга
|
||||
// относительных путей файлов в абсолютные при раскладке.
|
||||
func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.Result, string, error) {
|
||||
if !d.Infohash.Valid {
|
||||
if len(d.Infohashes) == 0 {
|
||||
return recognize.Result{}, "", fmt.Errorf("no infohash")
|
||||
}
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
|
||||
if err != nil {
|
||||
return recognize.Result{}, "", err
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func (w *Worker) runRecognize(ctx context.Context, d store.Download) (recognize.
|
||||
|
||||
// finishRecognition сохраняет попытку распознавания и двигает задачу. В Ф3
|
||||
// метабазы выключены → авто-раскладки не делаем, всегда уходим в review.
|
||||
func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.Result, savePath string) {
|
||||
func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize.Result, savePath string) {
|
||||
log := logctx.From(ctx)
|
||||
planJSON, err := json.Marshal(res.Plan)
|
||||
if err != nil {
|
||||
@@ -174,6 +174,9 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
||||
log.Error("recognition persist failed", "error", err)
|
||||
return
|
||||
}
|
||||
// recognition_id — ключ корреляции попытки (грепается и голым ULID).
|
||||
log.Info("recognition persisted", "recognition_id", recID,
|
||||
"provider", provider, "provider_id", providerID)
|
||||
// Кандидаты базы — для ручного выбора в review.
|
||||
if cands := toStoreCandidates(recID, res.Candidates); len(cands) > 0 {
|
||||
if err := w.store.CreateCandidates(ctx, cands); err != nil {
|
||||
@@ -189,7 +192,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
||||
forceReview := overrides[ovrForceReview] == "1"
|
||||
if res.Decision.Auto && !forceReview && w.layouter != nil {
|
||||
plan := applyOverrides(res.Plan, overrides)
|
||||
lctx := w.scoped(ctx, capFileLayout, id, d.Infohash.String)
|
||||
lctx := w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
w.transition(lctx, *d, store.StateLinking, "", "")
|
||||
if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil {
|
||||
logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err)
|
||||
@@ -200,7 +203,7 @@ func (w *Worker) finishRecognition(ctx context.Context, id int64, res recognize.
|
||||
}
|
||||
|
||||
// overridesOrNil читает правки, проглатывая ошибку (для авто-пути).
|
||||
func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string {
|
||||
func (w *Worker) overridesOrNil(ctx context.Context, id string) map[string]string {
|
||||
o, err := w.store.ListOverrides(ctx, id)
|
||||
if err != nil {
|
||||
logctx.From(ctx).Warn("recognition list overrides failed", "error", err)
|
||||
@@ -213,7 +216,7 @@ func (w *Worker) overridesOrNil(ctx context.Context, id int64) map[string]string
|
||||
|
||||
// Apply создаёт хардлинки по текущему плану (с применёнными правками) и
|
||||
// переводит задачу в done. Коллизия цели → остаёмся в review с причиной.
|
||||
func (w *Worker) Apply(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Apply(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.layouter == nil {
|
||||
@@ -225,15 +228,15 @@ func (w *Worker) Apply(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
if d.State != store.StateReview && d.State != store.StateDeferred {
|
||||
return fmt.Errorf("apply: download %d is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
|
||||
return fmt.Errorf("apply: download %s is in state %s (expected review/deferred): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
|
||||
plan, tag, err := w.effectivePlan(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.Infohash.String)
|
||||
t, ok, err := w.torrentByInfohash(ctx, d.HashList())
|
||||
if err != nil {
|
||||
return fmt.Errorf("apply: lookup torrent: %w", err)
|
||||
}
|
||||
@@ -308,7 +311,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
}
|
||||
|
||||
w.transition(ctx, *d, store.StateDone, "", "")
|
||||
logctx.From(ctx).Info("layout linked", "batch", batch, "links", len(fl))
|
||||
logctx.From(ctx).Info("layout linked", "batch_id", batch, "links", len(fl))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -317,7 +320,7 @@ func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize
|
||||
// перезапустит recognize. Авто-раскладку при этом не делаем — ручная
|
||||
// перепривязка всегда проходит через ревью с подтверждением (force_review).
|
||||
// Источник (раздача в qBittorrent) для этого должен быть на месте.
|
||||
func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Relink(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -326,28 +329,26 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
if d.State != store.StateReverted && d.State != store.StateCancelled && d.State != store.StateTargetMissing {
|
||||
return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
|
||||
return fmt.Errorf("relink: download %s is in state %s (expected reverted/cancelled/target_missing): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
// Источник нужен для распознавания — проверяем синхронно (без дебаунса) и при
|
||||
// его отсутствии приводим состояние к реальности (orphaned/deleted).
|
||||
if err := w.ensureSourcePresent(ctx, d, "relink"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Вернуть задачу в активную обработку можно, только если другой активной
|
||||
// задачи на этот infohash нет (partial unique index по idempotency_key).
|
||||
active, err := w.store.FindActiveByInfohash(ctx, d.Infohash.String)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
if active != nil {
|
||||
return fmt.Errorf("relink: для этого торрента уже есть активная задача #%d", active.ID)
|
||||
}
|
||||
// Ручная перепривязка — всегда с подтверждением, без авто-раскладки.
|
||||
if err := w.store.SetOverride(ctx, id, ovrForceReview, "1"); err != nil {
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
// Возврат в активную обработку — только через атомарный гард инварианта
|
||||
// «не более одной активной задачи на infohash» (см. design ulid-identity, D4).
|
||||
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateRecognizing, "", ""); err != nil {
|
||||
if errors.Is(err, store.ErrInfohashTaken) {
|
||||
return fmt.Errorf("relink: для этого торрента уже есть активная задача: %w", ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("relink: %w", err)
|
||||
}
|
||||
logctx.From(ctx).Info("relink re-recognizing", "from", d.State)
|
||||
return nil
|
||||
}
|
||||
@@ -355,7 +356,7 @@ func (w *Worker) Relink(ctx context.Context, id int64) error {
|
||||
// Rerecognize перезапускает распознавание для задачи в review/deferred без
|
||||
// добавления подсказки: контекст и прежние подсказки уже накоплены. Поллинг-
|
||||
// цикл проведёт задачу recognizing → review заново.
|
||||
func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Rerecognize(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -366,14 +367,14 @@ func (w *Worker) Rerecognize(ctx context.Context, id int64) error {
|
||||
if err := w.ensureSourcePresent(ctx, d, "rerecognize"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
logctx.From(ctx).Info("review re-recognizing without hint")
|
||||
w.transition(ctx, *d, store.StateRecognizing, "", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Refine добавляет подсказку и отправляет задачу на перераспознавание.
|
||||
func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||
func (w *Worker) Refine(ctx context.Context, id string, hint string) error {
|
||||
hint = strings.TrimSpace(hint)
|
||||
if hint == "" {
|
||||
return fmt.Errorf("refine: empty hint")
|
||||
@@ -388,7 +389,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||
if err := w.ensureSourcePresent(ctx, d, "refine"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
if err := w.store.AddHint(ctx, id, hint); err != nil {
|
||||
return fmt.Errorf("refine: %w", err)
|
||||
}
|
||||
@@ -399,7 +400,7 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error {
|
||||
|
||||
// SetType фиксирует тип (override) и перезапускает распознавание с подсказкой
|
||||
// — чтобы LLM пересобрал роли файлов под новый тип.
|
||||
func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error {
|
||||
func (w *Worker) SetType(ctx context.Context, id string, mediaType string) error {
|
||||
if mediaType != string(recognize.MediaMovie) && mediaType != string(recognize.MediaSeries) {
|
||||
return fmt.Errorf("set type: invalid type %q", mediaType)
|
||||
}
|
||||
@@ -413,7 +414,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
|
||||
if err := w.ensureSourcePresent(ctx, d, "set type"); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil {
|
||||
return fmt.Errorf("set type: %w", err)
|
||||
}
|
||||
@@ -430,7 +431,7 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error
|
||||
|
||||
// IgnoreFile помечает файл к игнорированию (не линкуем). Остаёмся в review;
|
||||
// превью пересчитается с учётом правки.
|
||||
func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
|
||||
func (w *Worker) IgnoreFile(ctx context.Context, id string, src string) error {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return fmt.Errorf("ignore: empty path")
|
||||
@@ -454,12 +455,12 @@ func (w *Worker) IgnoreFile(ctx context.Context, id int64, src string) error {
|
||||
if err := w.store.SetOverride(ctx, id, ovrIgnoredFiles, string(b)); err != nil {
|
||||
return fmt.Errorf("ignore: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review file ignored", "src", src)
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review file ignored", "src", src)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Defer паркует задачу в deferred (вернётся в ревью по действию).
|
||||
func (w *Worker) Defer(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Defer(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -468,9 +469,9 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("defer: %w", err)
|
||||
}
|
||||
if d.State.IsTerminal() {
|
||||
return fmt.Errorf("defer: download %d is terminal (%s)", id, d.State)
|
||||
return fmt.Errorf("defer: download %s is terminal (%s)", id, d.State)
|
||||
}
|
||||
ctx = w.scoped(ctx, capReview, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capReview, id, d.PrimaryInfohash())
|
||||
w.transition(ctx, *d, store.StateDeferred, "", "")
|
||||
return nil
|
||||
}
|
||||
@@ -479,7 +480,7 @@ func (w *Worker) Defer(ctx context.Context, id int64) error {
|
||||
// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат
|
||||
// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается
|
||||
// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation.
|
||||
func (w *Worker) Undo(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Undo(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.layouter == nil {
|
||||
@@ -496,9 +497,9 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("undo: источник удалён, цель — последняя копия данных, откат невозможен: %w", ErrConflict)
|
||||
}
|
||||
if d.State != store.StateDone {
|
||||
return fmt.Errorf("undo: download %d is in state %s (expected done): %w", id, d.State, ErrConflict)
|
||||
return fmt.Errorf("undo: download %s is in state %s (expected done): %w", id, d.State, ErrConflict)
|
||||
}
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash())
|
||||
batch, err := w.store.LatestBatchID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
@@ -528,18 +529,18 @@ func (w *Worker) Undo(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("undo: %w", err)
|
||||
}
|
||||
w.transition(ctx, *d, store.StateReverted, "", "")
|
||||
logctx.From(ctx).Info("layout reverted", "batch", batch, "removed", n)
|
||||
logctx.From(ctx).Info("layout reverted", "batch_id", batch, "removed", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// requireReviewable проверяет, что задача в review/deferred. Вызывается под mu.
|
||||
func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*store.Download, error) {
|
||||
func (w *Worker) requireReviewable(ctx context.Context, id string, op string) (*store.Download, error) {
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", op, err)
|
||||
}
|
||||
if d.State != store.StateReview && d.State != store.StateDeferred {
|
||||
return nil, fmt.Errorf("%s: download %d is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
|
||||
return nil, fmt.Errorf("%s: download %s is in state %s (expected review/deferred): %w", op, id, d.State, ErrConflict)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -549,7 +550,7 @@ func (w *Worker) requireReviewable(ctx context.Context, id int64, op string) (*s
|
||||
// ChooseCandidate пиннит выбранного кандидата базы как override (провайдер,
|
||||
// id, каноническое имя/год). Раскладку не запускает — превью обновится, а
|
||||
// человек подтвердит «Применить».
|
||||
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) error {
|
||||
func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -566,7 +567,7 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
if rec == nil || cand == nil || cand.RecognitionID != rec.ID {
|
||||
return fmt.Errorf("choose candidate: candidate %d does not belong to the current recognition", candidateID)
|
||||
return fmt.Errorf("choose candidate: candidate %s does not belong to the current recognition", candidateID)
|
||||
}
|
||||
|
||||
pins := map[string]string{ovrProvider: cand.Provider, ovrProviderID: cand.ProviderID}
|
||||
@@ -584,13 +585,13 @@ func (w *Worker) ChooseCandidate(ctx context.Context, id, candidateID int64) err
|
||||
if err := w.store.SetCandidateChosen(ctx, rec.ID, candidateID); err != nil {
|
||||
return fmt.Errorf("choose candidate: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review candidate chosen",
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review candidate chosen",
|
||||
"provider", cand.Provider, "provider_id", cand.ProviderID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProviderID пиннит провайдера и id вручную (без выбора из списка).
|
||||
func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, providerID string) error {
|
||||
func (w *Worker) SetProviderID(ctx context.Context, id string, provider, providerID string) error {
|
||||
provider = strings.TrimSpace(strings.ToLower(provider))
|
||||
providerID = strings.TrimSpace(providerID)
|
||||
switch provider {
|
||||
@@ -614,13 +615,13 @@ func (w *Worker) SetProviderID(ctx context.Context, id int64, provider, provider
|
||||
if err := w.store.SetOverride(ctx, id, ovrProviderID, providerID); err != nil {
|
||||
return fmt.Errorf("set provider: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider set",
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider set",
|
||||
"provider", provider, "provider_id", providerID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearProvider — «без базы»: снимает матч (тег папки не ставится).
|
||||
func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
|
||||
func (w *Worker) ClearProvider(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -634,7 +635,7 @@ func (w *Worker) ClearProvider(ctx context.Context, id int64) error {
|
||||
if err := w.store.SetOverride(ctx, id, ovrProviderID, ""); err != nil {
|
||||
return fmt.Errorf("clear provider: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("review provider cleared")
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("review provider cleared")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -654,12 +655,12 @@ type ReviewData struct {
|
||||
}
|
||||
|
||||
// ReviewData собирает данные ревью по загрузке.
|
||||
func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error) {
|
||||
func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) {
|
||||
d, err := w.store.GetDownload(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("review data: %w", err)
|
||||
}
|
||||
log := logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String))
|
||||
log := logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash()))
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("review data: %w", err)
|
||||
@@ -709,7 +710,7 @@ func (w *Worker) ReviewData(ctx context.Context, id int64) (*ReviewData, error)
|
||||
|
||||
// effectivePlan загружает текущий план, применяет правки и возвращает
|
||||
// provider-тег для имени папки (под mu).
|
||||
func (w *Worker) effectivePlan(ctx context.Context, id int64) (recognize.Plan, string, error) {
|
||||
func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) {
|
||||
rec, err := w.store.GetCurrentRecognition(ctx, id)
|
||||
if err != nil {
|
||||
return recognize.Plan{}, "", err
|
||||
@@ -772,7 +773,7 @@ func effectiveProvider(rec *store.Recognition, overrides map[string]string) (pro
|
||||
|
||||
// toStoreCandidates переводит кандидатов распознавания в строки БД,
|
||||
// подставляя тег-предпочтительный provider/id (внешний из TVMaze и т.п.).
|
||||
func toStoreCandidates(recognitionID int64, cands []metadata.Candidate) []store.MetadataCandidate {
|
||||
func toStoreCandidates(recognitionID string, cands []metadata.Candidate) []store.MetadataCandidate {
|
||||
out := make([]store.MetadataCandidate, 0, len(cands))
|
||||
for _, c := range cands {
|
||||
prov, id := recognize.CandidateTag(c)
|
||||
@@ -863,19 +864,22 @@ func mapRole(r recognize.FileRole) (layout.Role, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// torrentByInfohash ищет торрент по infohash (v1/v2/hash). Листаем ВСЕ
|
||||
// торренты (а не только свою категорию): раздача могла быть усыновлена по
|
||||
// тегу и иметь чужую/пустую категорию — фильтр по категории её бы потерял
|
||||
// (как и в Poll, см. там же).
|
||||
func (w *Worker) torrentByInfohash(ctx context.Context, infohash string) (qbt.Torrent, bool, error) {
|
||||
// torrentByInfohash ищет торрент по любому из хешей загрузки (v1/v2/hash).
|
||||
// Листаем ВСЕ торренты (а не только свою категорию): раздача могла быть
|
||||
// усыновлена по тегу и иметь чужую/пустую категорию — фильтр по категории её
|
||||
// бы потерял (как и в Poll, см. там же).
|
||||
func (w *Worker) torrentByInfohash(ctx context.Context, hashes []string) (qbt.Torrent, bool, error) {
|
||||
torrents, err := w.qbt.Torrents(ctx, "")
|
||||
if err != nil {
|
||||
return qbt.Torrent{}, false, err
|
||||
}
|
||||
want := strings.ToLower(infohash)
|
||||
want := make(map[string]bool, len(hashes))
|
||||
for _, h := range hashes {
|
||||
want[store.NormalizeHash(h)] = true
|
||||
}
|
||||
for _, t := range torrents {
|
||||
for _, h := range []string{t.Hash, t.InfohashV1, t.InfohashV2} {
|
||||
if h != "" && strings.ToLower(h) == want {
|
||||
if h != "" && want[strings.ToLower(h)] {
|
||||
return t, true, nil
|
||||
}
|
||||
}
|
||||
|
||||
+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: ""},
|
||||
})
|
||||
|
||||
+93
-42
@@ -13,12 +13,15 @@ package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/jellybit/internal/ident"
|
||||
"git.vakhrushev.me/av/jellybit/internal/layout"
|
||||
"git.vakhrushev.me/av/jellybit/internal/logctx"
|
||||
"git.vakhrushev.me/av/jellybit/internal/qbt"
|
||||
@@ -39,34 +42,35 @@ const (
|
||||
type Store interface {
|
||||
ListDownloadsByState(ctx context.Context, states ...store.State) ([]store.Download, error)
|
||||
ListRecoverable(ctx context.Context, codes ...string) ([]store.Download, error)
|
||||
GetDownload(ctx context.Context, id int64) (*store.Download, error)
|
||||
SetDownloadState(ctx context.Context, id int64, state store.State, errCode, errMsg string) error
|
||||
SetSourceMissCount(ctx context.Context, id int64, n int) error
|
||||
SetSourceAddedAt(ctx context.Context, id int64, t time.Time) error
|
||||
GetDownload(ctx context.Context, id string) (*store.Download, error)
|
||||
SetDownloadState(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||
SetSourceMissCount(ctx context.Context, id string, n int) error
|
||||
SetSourceAddedAt(ctx context.Context, id string, t time.Time) error
|
||||
|
||||
// Discovery (усыновление раздач по категории/тегу).
|
||||
ExistsByInfohash(ctx context.Context, infohash string) (bool, error)
|
||||
FindActiveByInfohash(ctx context.Context, infohash string) (*store.Download, error)
|
||||
CreateDownload(ctx context.Context, d *store.Download) (int64, error)
|
||||
// Идентичность/инвариант «одна активная загрузка на infohash».
|
||||
ExistsByInfohash(ctx context.Context, hashes ...string) (bool, error)
|
||||
CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error)
|
||||
ActivateIfNoOtherActive(ctx context.Context, id string, state store.State, errCode, errMsg string) error
|
||||
AddInfohashes(ctx context.Context, downloadID string, hashes []string) error
|
||||
|
||||
// Ф3: распознавание, ревью, раскладка.
|
||||
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (int64, error)
|
||||
GetCurrentRecognition(ctx context.Context, downloadID int64) (*store.Recognition, error)
|
||||
AddHint(ctx context.Context, downloadID int64, text string) error
|
||||
ListHints(ctx context.Context, downloadID int64) ([]string, error)
|
||||
SetOverride(ctx context.Context, downloadID int64, field, value string) error
|
||||
ListOverrides(ctx context.Context, downloadID int64) (map[string]string, error)
|
||||
CreateRecognition(ctx context.Context, r *store.Recognition, reasons []string) (string, error)
|
||||
GetCurrentRecognition(ctx context.Context, downloadID string) (*store.Recognition, error)
|
||||
AddHint(ctx context.Context, downloadID string, text string) error
|
||||
ListHints(ctx context.Context, downloadID string) ([]string, error)
|
||||
SetOverride(ctx context.Context, downloadID string, field, value string) error
|
||||
ListOverrides(ctx context.Context, downloadID string) (map[string]string, error)
|
||||
CreateFileLinks(ctx context.Context, links []store.FileLink) error
|
||||
SupersedeForeignLinks(ctx context.Context, downloadID int64, dstPaths []string) error
|
||||
LatestBatchID(ctx context.Context, downloadID int64) (string, error)
|
||||
SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error
|
||||
LatestBatchID(ctx context.Context, downloadID string) (string, error)
|
||||
ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error)
|
||||
DeleteFileLinksByBatch(ctx context.Context, batchID string) error
|
||||
|
||||
// Кандидаты базы метаданных (ручной выбор в review).
|
||||
CreateCandidates(ctx context.Context, cands []store.MetadataCandidate) error
|
||||
ListCandidatesByRecognition(ctx context.Context, recognitionID int64) ([]store.MetadataCandidate, error)
|
||||
GetCandidate(ctx context.Context, id int64) (*store.MetadataCandidate, error)
|
||||
SetCandidateChosen(ctx context.Context, recognitionID, candidateID int64) error
|
||||
ListCandidatesByRecognition(ctx context.Context, recognitionID string) ([]store.MetadataCandidate, error)
|
||||
GetCandidate(ctx context.Context, id string) (*store.MetadataCandidate, error)
|
||||
SetCandidateChosen(ctx context.Context, recognitionID, candidateID string) error
|
||||
}
|
||||
|
||||
// QBittorrent — нужная worker часть клиента qBittorrent.
|
||||
@@ -111,7 +115,7 @@ const (
|
||||
|
||||
// Notifier — исходящие пинги (Telegram). Вызывается неблокирующе.
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, downloadID int64, event NotifyEvent)
|
||||
Notify(ctx context.Context, downloadID string, event NotifyEvent)
|
||||
}
|
||||
|
||||
// Scanner — триггер пересканирования медиатеки Jellyfin. Вызывается
|
||||
@@ -196,7 +200,7 @@ type Worker struct {
|
||||
// время последнего пинга). Мерцающий stalled-торрент колеблется
|
||||
// stuck↔downloading; без дебаунса каждый цикл слал бы уведомление. Память
|
||||
// процесса: при рестарте дебаунс сбрасывается — допустимо. Доступ под w.mu.
|
||||
failNotified map[int64]time.Time
|
||||
failNotified map[string]time.Time
|
||||
}
|
||||
|
||||
// failNotifyDebounce — минимальный интервал между уведомлениями о падении
|
||||
@@ -221,7 +225,7 @@ func New(st Store, qb QBittorrent, rec Recognizer, lay Layouter, cfg Config, log
|
||||
log: log,
|
||||
now: time.Now,
|
||||
newID: defaultBatchID,
|
||||
failNotified: map[int64]time.Time{},
|
||||
failNotified: map[string]time.Time{},
|
||||
live: map[string]Live{},
|
||||
}
|
||||
}
|
||||
@@ -246,15 +250,16 @@ func (w *Worker) setLive(snap map[string]Live) {
|
||||
w.liveMu.Unlock()
|
||||
}
|
||||
|
||||
// defaultBatchID — уникальный идентификатор батча раскладки.
|
||||
// defaultBatchID — идентификатор батча раскладки (ULID, единая точка
|
||||
// генерации id — internal/ident; сортируем по времени, удобен в логах).
|
||||
func defaultBatchID() string {
|
||||
return fmt.Sprintf("b-%d", time.Now().UnixNano())
|
||||
return ident.NewID()
|
||||
}
|
||||
|
||||
// scoped кладёт в ctx scoped-логгер загрузки (capability + download_id
|
||||
// [+ infohash]); стадии и внешние клиенты достают его из ctx и дописывают эти
|
||||
// ключи на каждую запись сами — без ручного доклеивания download_id.
|
||||
func (w *Worker) scoped(ctx context.Context, capability string, id int64, infohash string) context.Context {
|
||||
func (w *Worker) scoped(ctx context.Context, capability string, id string, infohash string) context.Context {
|
||||
log := w.log.With("capability", capability, "download_id", id)
|
||||
if infohash != "" {
|
||||
log = log.With("infohash", infohash)
|
||||
@@ -325,15 +330,16 @@ func (w *Worker) Poll(ctx context.Context) error {
|
||||
return fmt.Errorf("poll: list active: %w", err)
|
||||
}
|
||||
for _, d := range active {
|
||||
if !d.Infohash.Valid {
|
||||
if len(d.Infohashes) == 0 {
|
||||
continue // нечем сопоставить (в Ф1 не случается: magnet всегда с infohash)
|
||||
}
|
||||
t, ok := byHash[strings.ToLower(d.Infohash.String)]
|
||||
t, ok := torrentFor(d, byHash)
|
||||
if !ok {
|
||||
w.log.Warn("active download not found in qbittorrent",
|
||||
"capability", capIngest, "download_id", d.ID, "infohash", d.Infohash.String)
|
||||
"capability", capIngest, "download_id", d.ID, "infohash", d.PrimaryInfohash())
|
||||
continue
|
||||
}
|
||||
w.captureInfohashes(ctx, d, t)
|
||||
w.captureSourceAddedAt(ctx, d, t)
|
||||
w.reconcile(ctx, d, t)
|
||||
}
|
||||
@@ -351,7 +357,7 @@ func (w *Worker) Poll(ctx context.Context) error {
|
||||
// reconcile двигает одну задачу по состоянию её торрента. Вызывается под
|
||||
// w.mu.
|
||||
func (w *Worker) reconcile(ctx context.Context, d store.Download, t qbt.Torrent) {
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String)
|
||||
ctx = w.scoped(ctx, capIngest, d.ID, d.PrimaryInfohash())
|
||||
switch classify(t.State) {
|
||||
case classReady:
|
||||
w.transition(ctx, d, store.StateCompleted, "", "")
|
||||
@@ -397,6 +403,39 @@ func (w *Worker) captureSourceAddedAt(ctx context.Context, d store.Download, t q
|
||||
}
|
||||
}
|
||||
|
||||
// torrentFor ищет торрент загрузки в карте byHash по любому из её хешей.
|
||||
func torrentFor(d store.Download, byHash map[string]qbt.Torrent) (qbt.Torrent, bool) {
|
||||
for _, h := range d.HashList() {
|
||||
if t, ok := byHash[h]; ok {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return qbt.Torrent{}, false
|
||||
}
|
||||
|
||||
// captureInfohashes дописывает загрузке хеши, которые qBittorrent знает, а мы
|
||||
// ещё нет (гибридный торрент раскрывает v1+v2 после получения метаданных).
|
||||
// Хеши собирает torrentHashes (усечённый t.Hash v2-only раздач отсеян).
|
||||
// AddInfohashes под гардом: хеш, которым владеет другая активная задача,
|
||||
// дописан не будет (ErrInfohashTaken). Учётная операция: сбой не двигает
|
||||
// задачу, лишь логируем WARN. Под w.mu.
|
||||
func (w *Worker) captureInfohashes(ctx context.Context, d store.Download, t qbt.Torrent) {
|
||||
known := d.HashList()
|
||||
var missing []string
|
||||
for _, h := range torrentHashes(t) {
|
||||
if !slices.Contains(known, h) {
|
||||
missing = append(missing, h)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return
|
||||
}
|
||||
if err := w.store.AddInfohashes(ctx, d.ID, missing); err != nil {
|
||||
w.log.Warn("capture infohashes failed",
|
||||
"capability", capIngest, "download_id", d.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// torrentAge — возраст торрента: от added_on в qBittorrent (надёжный базис,
|
||||
// переживает retry/усыновление), с фолбэком на created_at задачи, если qBit не
|
||||
// отдал added_on.
|
||||
@@ -453,7 +492,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
||||
// Скан Jellyfin — неблокирующе и вне w.mu, в фоновом ctx со scoped-логгером
|
||||
// (download_id для корреляции ext.*-записи клиента). Недоступность Jellyfin
|
||||
// на задачу не влияет; ошибку вызова логирует сам клиент (ext.*), здесь гасим.
|
||||
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.Infohash.String)
|
||||
gctx := w.scoped(context.Background(), capFileLayout, d.ID, d.PrimaryInfohash())
|
||||
go func() { _ = w.scanner.RefreshLibraries(gctx) }()
|
||||
}
|
||||
}
|
||||
@@ -462,7 +501,7 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S
|
||||
// (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается
|
||||
// под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
|
||||
// флаппинг.
|
||||
func (w *Worker) shouldNotifyFail(id int64) bool {
|
||||
func (w *Worker) shouldNotifyFail(id string) bool {
|
||||
now := w.now()
|
||||
if last, ok := w.failNotified[id]; ok && now.Sub(last) < failNotifyDebounce {
|
||||
return false
|
||||
@@ -479,7 +518,7 @@ func (w *Worker) shouldNotifyFail(id int64) bool {
|
||||
|
||||
// Cancel отклоняет задачу. Торрент в qBittorrent не трогаем — он продолжает
|
||||
// раздачу (источник неприкосновенен).
|
||||
func (w *Worker) Cancel(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Cancel(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -488,18 +527,18 @@ func (w *Worker) Cancel(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("cancel: %w", err)
|
||||
}
|
||||
if d.State.IsTerminal() {
|
||||
return fmt.Errorf("cancel: download %d is already terminal (%s)", id, d.State)
|
||||
return fmt.Errorf("cancel: download %s is already terminal (%s)", id, d.State)
|
||||
}
|
||||
if err := w.store.SetDownloadState(ctx, id, store.StateCancelled, "", ""); err != nil {
|
||||
return fmt.Errorf("cancel: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download cancelled", "from", d.State)
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download cancelled", "from", d.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Retry повторяет застрявшую/упавшую задачу: заново отдаёт источник в
|
||||
// qBittorrent и возвращает в downloading.
|
||||
func (w *Worker) Retry(ctx context.Context, id int64) error {
|
||||
func (w *Worker) Retry(ctx context.Context, id string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
@@ -508,32 +547,44 @@ func (w *Worker) Retry(ctx context.Context, id int64) error {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
if d.State != store.StateFailed && d.State != store.StateStuck {
|
||||
return fmt.Errorf("retry: download %d is %s, only failed/stuck are retriable", id, d.State)
|
||||
return fmt.Errorf("retry: download %s is %s, only failed/stuck are retriable", id, d.State)
|
||||
}
|
||||
// Если раздача уже жива в qBittorrent — перецепляемся к ней, повторный Add
|
||||
// не нужен (и вреден: вслепую дублировал бы торрент). Add — только когда
|
||||
// источника в qBittorrent нет. Базис таймаута берётся от added_on, поэтому
|
||||
// возврат в downloading не роняет задачу снова на ближайшем тике.
|
||||
alive := false
|
||||
if d.Infohash.Valid {
|
||||
_, alive, err = w.torrentByInfohash(ctx, d.Infohash.String)
|
||||
if hashes := d.HashList(); len(hashes) > 0 {
|
||||
_, alive, err = w.torrentByInfohash(ctx, hashes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
}
|
||||
// Гард инварианта — ДО побочного эффекта в qBittorrent: пока задача лежала
|
||||
// в failed, тем же infohash могла завладеть другая активная задача — тогда
|
||||
// отказываем, не добавив торрент повторно (см. design ulid-identity, D4).
|
||||
if err := w.store.ActivateIfNoOtherActive(ctx, id, store.StateDownloading, "", ""); err != nil {
|
||||
if errors.Is(err, store.ErrInfohashTaken) {
|
||||
return fmt.Errorf("retry: для этого торрента уже есть другая активная задача: %w", ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
if !alive && d.SourceType == store.SourceMagnet {
|
||||
if err := w.qbt.Add(ctx, qbt.AddRequest{
|
||||
URLs: []string{d.SourceRef},
|
||||
Category: w.cfg.Category,
|
||||
SavePath: w.cfg.SavePath,
|
||||
}); err != nil {
|
||||
// Активация уже прошла — откатываем задачу в прежнее состояние,
|
||||
// чтобы не оставить «качающуюся» задачу без раздачи в qBittorrent.
|
||||
if rbErr := w.store.SetDownloadState(ctx, id, d.State, d.ErrorCode.String, d.ErrorMsg.String); rbErr != nil {
|
||||
w.log.Error("retry rollback failed",
|
||||
"capability", capReview, "download_id", id, "error", rbErr)
|
||||
}
|
||||
return fmt.Errorf("retry: add to qbittorrent: %w", err)
|
||||
}
|
||||
}
|
||||
if err := w.store.SetDownloadState(ctx, id, store.StateDownloading, "", ""); err != nil {
|
||||
return fmt.Errorf("retry: %w", err)
|
||||
}
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.Infohash.String)).Info("download retried", "from", d.State)
|
||||
logctx.From(w.scoped(ctx, capReview, id, d.PrimaryInfohash())).Info("download retried", "from", d.State)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+165
-47
@@ -2,6 +2,7 @@ package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -20,12 +21,12 @@ const (
|
||||
)
|
||||
|
||||
type fakeStore struct {
|
||||
downloads map[int64]*store.Download
|
||||
downloads map[string]*store.Download
|
||||
transitions []transition
|
||||
}
|
||||
|
||||
type transition struct {
|
||||
id int64
|
||||
id string
|
||||
state store.State
|
||||
}
|
||||
|
||||
@@ -58,27 +59,39 @@ func (f *fakeStore) ListRecoverable(_ context.Context, codes ...string) ([]store
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) GetDownload(_ context.Context, id int64) (*store.Download, error) {
|
||||
func (f *fakeStore) GetDownload(_ context.Context, id string) (*store.Download, error) {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("download %d not found", id)
|
||||
return nil, fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
cp := *d
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) ExistsByInfohash(_ context.Context, infohash string) (bool, error) {
|
||||
// hasAnyHash сообщает, владеет ли загрузка любым из hashes.
|
||||
func hasAnyHash(d *store.Download, hashes []string) bool {
|
||||
for _, own := range d.Infohashes {
|
||||
for _, h := range hashes {
|
||||
if own.Infohash == store.NormalizeHash(h) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (f *fakeStore) ExistsByInfohash(_ context.Context, hashes ...string) (bool, error) {
|
||||
for _, d := range f.downloads {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash {
|
||||
if hasAnyHash(d, hashes) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*store.Download, error) {
|
||||
func (f *fakeStore) FindActiveByInfohash(_ context.Context, hashes ...string) (*store.Download, error) {
|
||||
for _, d := range f.downloads {
|
||||
if d.Infohash.Valid && d.Infohash.String == infohash && !d.State.IsTerminal() {
|
||||
if hasAnyHash(d, hashes) && !d.State.IsTerminal() {
|
||||
cp := *d
|
||||
return &cp, nil
|
||||
}
|
||||
@@ -86,18 +99,62 @@ func (f *fakeStore) FindActiveByInfohash(_ context.Context, infohash string) (*s
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) CreateDownload(_ context.Context, d *store.Download) (int64, error) {
|
||||
id := int64(len(f.downloads) + 1)
|
||||
func (f *fakeStore) CreateDownloadIfNoActive(ctx context.Context, d *store.Download, hashes []string) (*store.Download, error) {
|
||||
if existing, _ := f.FindActiveByInfohash(ctx, hashes...); existing != nil {
|
||||
return existing, nil
|
||||
}
|
||||
id := fmt.Sprintf("%d", len(f.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)})
|
||||
}
|
||||
f.downloads[id] = &cp
|
||||
return id, nil
|
||||
d.ID = id
|
||||
d.Infohashes = cp.Infohashes
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State, code, msg string) error {
|
||||
func (f *fakeStore) ActivateIfNoOtherActive(ctx context.Context, id string, st store.State, code, msg string) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %d not found", id)
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
for _, other := range f.downloads {
|
||||
if other.ID != id && !other.State.IsTerminal() && hasAnyHash(other, hashList(d)) {
|
||||
return fmt.Errorf("activate %s: %w", id, store.ErrInfohashTaken)
|
||||
}
|
||||
}
|
||||
return f.SetDownloadState(ctx, id, st, code, msg)
|
||||
}
|
||||
|
||||
func hashList(d *store.Download) []string {
|
||||
out := make([]string, len(d.Infohashes))
|
||||
for i, h := range d.Infohashes {
|
||||
out[i] = h.Infohash
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (f *fakeStore) AddInfohashes(_ context.Context, id string, hashes []string) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
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 (f *fakeStore) SetDownloadState(_ context.Context, id string, st store.State, code, msg string) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
d.State = st
|
||||
d.ErrorCode = store.NullString(code)
|
||||
@@ -106,19 +163,19 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetSourceMissCount(_ context.Context, id int64, n int) error {
|
||||
func (f *fakeStore) SetSourceMissCount(_ context.Context, id string, n int) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %d not found", id)
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
d.SourceMissCount = n
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) error {
|
||||
func (f *fakeStore) SetSourceAddedAt(_ context.Context, id string, t time.Time) error {
|
||||
d, ok := f.downloads[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("download %d not found", id)
|
||||
return fmt.Errorf("download %s not found", id)
|
||||
}
|
||||
if !d.SourceAddedAt.Valid { // гард как в store: пишем однократно
|
||||
d.SourceAddedAt = store.NullString(store.FormatTime(t))
|
||||
@@ -128,23 +185,23 @@ func (f *fakeStore) SetSourceAddedAt(_ context.Context, id int64, t time.Time) e
|
||||
|
||||
// --- Ф3-методы Store (заглушки; переопределяются в review_test.go) ---
|
||||
|
||||
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) {
|
||||
return 0, nil
|
||||
func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ int64) (*store.Recognition, error) {
|
||||
func (f *fakeStore) GetCurrentRecognition(_ context.Context, _ string) (*store.Recognition, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) AddHint(_ context.Context, _ int64, _ string) error { return nil }
|
||||
func (f *fakeStore) ListHints(_ context.Context, _ int64) ([]string, error) { return nil, nil }
|
||||
func (f *fakeStore) SetOverride(_ context.Context, _ int64, _, _ string) error { return nil }
|
||||
func (f *fakeStore) ListOverrides(_ context.Context, _ int64) (map[string]string, error) {
|
||||
func (f *fakeStore) AddHint(_ context.Context, _ string, _ string) error { return nil }
|
||||
func (f *fakeStore) ListHints(_ context.Context, _ string) ([]string, error) { return nil, nil }
|
||||
func (f *fakeStore) SetOverride(_ context.Context, _ string, _, _ string) error { return nil }
|
||||
func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil }
|
||||
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ int64, _ []string) error {
|
||||
func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) LatestBatchID(_ context.Context, _ int64) (string, error) { return "", nil }
|
||||
func (f *fakeStore) LatestBatchID(_ context.Context, _ string) (string, error) { return "", nil }
|
||||
func (f *fakeStore) ListFileLinksByBatch(_ context.Context, _ string) ([]store.FileLink, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -152,17 +209,18 @@ func (f *fakeStore) DeleteFileLinksByBatch(_ context.Context, _ string) error {
|
||||
func (f *fakeStore) CreateCandidates(_ context.Context, _ []store.MetadataCandidate) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ int64) ([]store.MetadataCandidate, error) {
|
||||
func (f *fakeStore) ListCandidatesByRecognition(_ context.Context, _ string) ([]store.MetadataCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) GetCandidate(_ context.Context, _ int64) (*store.MetadataCandidate, error) {
|
||||
func (f *fakeStore) GetCandidate(_ context.Context, _ string) (*store.MetadataCandidate, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ int64) error { return nil }
|
||||
func (f *fakeStore) SetCandidateChosen(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
type fakeQbt struct {
|
||||
torrents []qbt.Torrent
|
||||
added []qbt.AddRequest
|
||||
addErr error
|
||||
files []qbt.File
|
||||
}
|
||||
|
||||
@@ -184,6 +242,9 @@ func (f *fakeQbt) Torrents(_ context.Context, category string) ([]qbt.Torrent, e
|
||||
}
|
||||
|
||||
func (f *fakeQbt) Add(_ context.Context, ar qbt.AddRequest) error {
|
||||
if f.addErr != nil {
|
||||
return f.addErr
|
||||
}
|
||||
f.added = append(f.added, ar)
|
||||
return nil
|
||||
}
|
||||
@@ -204,18 +265,28 @@ func newTestWorker(st *fakeStore, qb *fakeQbt) *Worker {
|
||||
}
|
||||
|
||||
func oneDownloading(infohash, createdAt string) *fakeStore {
|
||||
return &fakeStore{downloads: map[int64]*store.Download{
|
||||
1: {
|
||||
ID: 1,
|
||||
return &fakeStore{downloads: map[string]*store.Download{
|
||||
"1": {
|
||||
ID: "1",
|
||||
State: store.StateDownloading,
|
||||
SourceType: store.SourceMagnet,
|
||||
SourceRef: "magnet:?xt=urn:btih:" + infohash,
|
||||
Infohash: store.NullString(infohash),
|
||||
Infohashes: hashesOf("1", infohash),
|
||||
CreatedAt: createdAt,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// hashesOf — срез хешей загрузки для литералов фикстур.
|
||||
func hashesOf(id string, hashes ...string) []store.Infohash {
|
||||
out := make([]store.Infohash, 0, len(hashes))
|
||||
for _, h := range hashes {
|
||||
h = store.NormalizeHash(h)
|
||||
out = append(out, store.Infohash{DownloadID: id, Infohash: h, Kind: store.HashKind(h)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestPollTransitions(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
tests := []struct {
|
||||
@@ -242,7 +313,7 @@ func TestPollTransitions(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatalf("Poll: %v", err)
|
||||
}
|
||||
if got := st.downloads[1].State; got != tc.want {
|
||||
if got := st.downloads["1"].State; got != tc.want {
|
||||
t.Errorf("state = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
@@ -257,8 +328,8 @@ func TestPollMatchesByInfohashV2(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateCompleted {
|
||||
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateCompleted {
|
||||
t.Errorf("сопоставление по infohash_v2 не сработало: %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,46 +340,93 @@ func TestPollIgnoresMissingTorrent(t *testing.T) {
|
||||
if err := w.Poll(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateDownloading {
|
||||
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("без торрента состояние не должно меняться, got %q", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancel(t *testing.T) {
|
||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
if err := w.Cancel(context.Background(), 1); err != nil {
|
||||
if err := w.Cancel(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Cancel: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateCancelled {
|
||||
t.Errorf("state = %q, want cancelled", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateCancelled {
|
||||
t.Errorf("state = %q, want cancelled", st.downloads["1"].State)
|
||||
}
|
||||
// Повторная отмена терминальной задачи — ошибка.
|
||||
if err := w.Cancel(context.Background(), 1); err == nil {
|
||||
if err := w.Cancel(context.Background(), "1"); err == nil {
|
||||
t.Error("ожидалась ошибка при отмене терминальной задачи")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetry(t *testing.T) {
|
||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||
st.downloads[1].State = store.StateStuck
|
||||
st.downloads["1"].State = store.StateStuck
|
||||
qb := &fakeQbt{}
|
||||
w := newTestWorker(st, qb)
|
||||
if err := w.Retry(context.Background(), 1); err != nil {
|
||||
if err := w.Retry(context.Background(), "1"); err != nil {
|
||||
t.Fatalf("Retry: %v", err)
|
||||
}
|
||||
if st.downloads[1].State != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading", st.downloads[1].State)
|
||||
if st.downloads["1"].State != store.StateDownloading {
|
||||
t.Errorf("state = %q, want downloading", st.downloads["1"].State)
|
||||
}
|
||||
if len(qb.added) != 1 {
|
||||
t.Errorf("ожидалось повторное добавление в qBittorrent, got %d", len(qb.added))
|
||||
}
|
||||
}
|
||||
|
||||
// Retry при занятом хеше отклоняется ДО побочного эффекта: торрент не
|
||||
// добавляется в qBittorrent повторно.
|
||||
func TestRetryConflictNoAdd(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneDownloading(ih, timeRecent)
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
|
||||
// Хешем владеет другая активная задача.
|
||||
st.downloads["2"] = &store.Download{
|
||||
ID: "2", State: store.StateDownloading, SourceType: store.SourceMagnet,
|
||||
Infohashes: hashesOf("2", ih), CreatedAt: timeRecent,
|
||||
}
|
||||
qb := &fakeQbt{} // торрента в qBittorrent нет — без гарда был бы Add
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); !errors.Is(err, ErrConflict) {
|
||||
t.Fatalf("ожидался ErrConflict, получили %v", err)
|
||||
}
|
||||
if len(qb.added) != 0 {
|
||||
t.Errorf("торрент добавлен побочным эффектом отклонённого retry: %d Add", len(qb.added))
|
||||
}
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("state = %s, want failed", st.downloads["1"].State)
|
||||
}
|
||||
}
|
||||
|
||||
// Если после активации повторный Add в qBittorrent упал — задача
|
||||
// откатывается в прежнее состояние, а не остаётся «качающейся» без раздачи.
|
||||
func TestRetryRollsBackOnAddFailure(t *testing.T) {
|
||||
const ih = "541adcff3b6dd5dba7088ea83317d9d6fac331d6"
|
||||
st := oneDownloading(ih, timeRecent)
|
||||
st.downloads["1"].State = store.StateFailed
|
||||
st.downloads["1"].ErrorCode = store.NullString("magnet_timeout")
|
||||
qb := &fakeQbt{addErr: fmt.Errorf("connection refused")}
|
||||
w := newTestWorker(st, qb)
|
||||
|
||||
if err := w.Retry(context.Background(), "1"); err == nil {
|
||||
t.Fatal("ожидалась ошибка Add")
|
||||
}
|
||||
if st.downloads["1"].State != store.StateFailed {
|
||||
t.Errorf("state = %s, want failed (откат)", st.downloads["1"].State)
|
||||
}
|
||||
if st.downloads["1"].ErrorCode.String != "magnet_timeout" {
|
||||
t.Errorf("error_code = %q, want magnet_timeout (восстановлен)", st.downloads["1"].ErrorCode.String)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryRejectsActive(t *testing.T) {
|
||||
st := oneDownloading("541adcff3b6dd5dba7088ea83317d9d6fac331d6", timeRecent)
|
||||
w := newTestWorker(st, &fakeQbt{})
|
||||
if err := w.Retry(context.Background(), 1); err == nil {
|
||||
if err := w.Retry(context.Background(), "1"); err == nil {
|
||||
t.Error("retry активной (downloading) задачи должен отклоняться")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user