diff --git a/cmd/jellybit/serve.go b/cmd/jellybit/serve.go index 7f6e062..69e8040 100644 --- a/cmd/jellybit/serve.go +++ b/cmd/jellybit/serve.go @@ -119,13 +119,14 @@ func runServe(args []string) error { } wrk := worker.New(st, qb, recognizer, layouter, worker.Config{ - Category: cfg.QBittorrent.Category, - Tag: cfg.QBittorrent.Tag, - SavePath: cfg.QBittorrent.SavePath, - PathMap: cfg.QBittorrent.PathMap, - PollInterval: cfg.Worker.PollInterval.Std(), - StuckAfter: cfg.Worker.StuckAfter.Std(), - MagnetTimeout: cfg.Worker.MagnetTimeout.Std(), + Category: cfg.QBittorrent.Category, + Tag: cfg.QBittorrent.Tag, + SavePath: cfg.QBittorrent.SavePath, + PathMap: cfg.QBittorrent.PathMap, + PollInterval: cfg.Worker.PollInterval.Std(), + StuckAfter: cfg.Worker.StuckAfter.Std(), + MagnetTimeout: cfg.Worker.MagnetTimeout.Std(), + SourceMissingThreshold: cfg.Worker.SourceMissingThreshold, }, logger) // Пересканирование Jellyfin после раскладки (опц.). Недоступность Jellyfin diff --git a/config.example.toml b/config.example.toml index c8bc3af..a7fca11 100644 --- a/config.example.toml +++ b/config.example.toml @@ -62,6 +62,7 @@ timeout = "10s" # таймаут запроса к Jellyfin poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h) stuck_after = "1h" # сколько ждать прогресса, прежде чем счесть раздачу зависшей; Go-duration magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration +source_missing_threshold = 3 # подряд тиков сверки без раздачи в qBittorrent, чтобы счесть источник удалённым (дебаунс) [recognition] auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0 diff --git a/docs/conventions/config.md b/docs/conventions/config.md index 512964c..fd9c0f1 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -41,6 +41,7 @@ [worker] poll_interval = "5s" # как часто опрашивать qBittorrent; Go-duration (s/m/h) magnet_timeout = "30m" # ждать метаданные magnet не дольше; Go-duration +source_missing_threshold = 3 # тиков сверки без раздачи, чтобы счесть источник удалённым [recognition] auto_confidence_threshold = 0.85 # порог авто-раскладки без ревью; доля 0.0–1.0 diff --git a/docs/specs/database.md b/docs/specs/database.md index f0af67f..a85567e 100644 --- a/docs/specs/database.md +++ b/docs/specs/database.md @@ -9,7 +9,8 @@ > в том же change. Расхождение схемы с миграциями считаем багом > документации. > -> Состояние на: миграции `0001_init`, `0002_recognition_plan`. +> Состояние на: миграции `0001_init`, `0002_recognition_plan`, +> `0003_source_miss_count`. Назначение таблиц и почему так — [architecture.md](architecture.md) → «Хранилище». Значения `state` и переходы — [workflow.md](workflow.md). @@ -34,6 +35,7 @@ erDiagram TEXT state "NOT NULL; см. workflow.md" TEXT error_code "nullable" TEXT error_msg "nullable" + INTEGER source_miss_count "NOT NULL DEFAULT 0; дебаунс пропажи источника (миграция 0003)" TEXT created_at "NOT NULL DEFAULT datetime('now')" TEXT updated_at "NOT NULL DEFAULT datetime('now')" } diff --git a/docs/specs/jellyfin-layout.md b/docs/specs/jellyfin-layout.md index 9916005..7e021db 100644 --- a/docs/specs/jellyfin-layout.md +++ b/docs/specs/jellyfin-layout.md @@ -57,6 +57,18 @@ inode общий — диск не дублируется. поддержки жёстких ссылок), `layout` не падает, а копирует файл с предупреждением в лог — см. architecture.md → «Раскладка файлов». +## Безопасный undo (не снимать последнюю копию) + +`Undo` снимает **лишний** хардлинк, а не единственный файл. Перед удалением +батча `layout` проверяет каждую цель: если исходный файл уже не существует +**или** у цели не осталось других жёстких ссылок (`nlink <= 1`), это — +последняя копия данных, и весь `Undo` отклоняется целиком (ошибка +`ErrLastCopy`), не сняв ни одной ссылки (частичный откат тоже стёр бы часть +данных). Так нарушенный инвариант «источник неприкосновенен» (источник +удалён вручную) не приводит к потере данных. Отсутствующую цель `Undo` +пропускает как уже снятую (идемпотентность). Связь с состояниями +рассинхрона — [workflow.md](workflow.md) → «Сверка с реальностью». + ## Крайние случаи - **Многофайловый фильм** (части) — стэкинг по точному токену Jellyfin diff --git a/docs/specs/workflow.md b/docs/specs/workflow.md index c72a9a4..e0ebba4 100644 --- a/docs/specs/workflow.md +++ b/docs/specs/workflow.md @@ -39,9 +39,19 @@ stateDiagram-v2 stuck --> downloading: Retry failed --> downloading: Retry + done --> target_missing: сверка — цель удалена + done --> orphaned: сверка — источник пропал + target_missing --> recognizing: Привязать заново + target_missing --> orphaned: источник тоже пропал + target_missing --> deleted: источник тоже пропал + orphaned --> deleted: цель тоже удалена + target_missing --> done: healing (цель вернулась) + orphaned --> done: healing (источник вернулся) + done --> [*] cancelled --> [*] reverted --> [*] + deleted --> [*] note right of cancelled «Отклонить» доступно из любого @@ -85,6 +95,30 @@ stateDiagram-v2 (авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в qBittorrent. +## Сверка с реальностью (рассинхрон) + +Состояние в БД может разойтись с диском при **ручном** удалении: раздачу +стирают из qBittorrent (источник) или файлы убирают из Jellyfin (целевые +хардлинки). `worker` периодически сверяет уже разложенные задачи с фактом по +двумерной матрице «источник × цель» (источник = раздача в qBittorrent, +цель = разложенные хардлинки на ФС) и выводит состояние: + +- **target_missing** — источник на месте, цель удалена. Доступна команда + «Привязать заново» (`→ recognizing`); авто-действий нет. +- **orphaned** — источник пропал, цель (последняя копия данных) на месте. + Команд вперёд нет; `Undo` запрещён (снял бы единственную копию). +- **deleted** — нет ни источника, ни цели; терминально. + +Сверка трогает только `done`/`target_missing`/`orphaned`/`deleted` — +активные и пользовательски-терминальные (`reverted`/`cancelled`/`failed`/ +`stuck`) состояния не задевает. Реальность «лечится» сама: при возврате +источника/цели задача переходит обратно (вплоть до `done`). Пропажа +**источника** дебаунсится (`[worker].source_missing_threshold` подряд идущих +тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды, +которым нужен источник (relink/распознать/применить/undo), проверяют его +**синхронно перед действием** и не полагаются на фоновую сверку. Полные +требования — `openspec/specs/state-reconciliation/`. + Все переходы и команды идут через `worker` под per-download блокировкой — два транспорта не гонятся за одно состояние. Состояние персистентно в SQLite; `worker` периодически сверяет qBittorrent с БД и **усыновляет** diff --git a/docs/todo.md b/docs/todo.md index 09f1458..011b56d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -25,38 +25,25 @@ матч в базе), [jellyfin-layout.md](specs/jellyfin-layout.md) (папка сериала с провайдер-id). -### Рассинхрон состояния с реальностью (удалённый торрент / файлы) +### Удаление средствами jellybit («единое окно», path 2) -Состояние jellybit может разойтись с тем, что реально лежит на диске. -Несколько сценариев разной остроты: +Распознавание **ручного** удаления (источник из qBittorrent / цель из +Jellyfin) и пометка рассинхрона уже сделаны: фоновая сверка по матрице +«источник × цель» → состояния `target_missing`/`orphaned`/`deleted`, +безопасный `undo` (не снимает последнюю копию, `nlink <= 1`), синхронный +preflight перед действиями. См. `openspec/specs/state-reconciliation/`, +[workflow.md](specs/workflow.md) → «Сверка с реальностью». -- **Жёсткий — удалён источник.** Раздачу удаляют (вручную или авто по - достижении seed limit), и qBittorrent стирает скачанные файлы. Тогда - хардлинк в библиотеке становится **последней** ссылкой на inode, и - обычный `undo` (`unlink` цели + чистка пустых каталогов) сотрёт - единственную копию насовсем — прямая потеря данных. Инвариант «источник - неприкосновенен» молчаливо перестаёт держаться: источника уже нет. -- **Мягкий — удалена цель.** Файлы убрали из библиотеки Jellyfin (вручную - или из самого Jellyfin), а jellybit по-прежнему числит загрузку в - `done`. Состояние врёт: ссылок уже нет, а сервис думает, что всё - разложено. - -Нужно продумать сверку записанного состояния (`file_link`, состояние -загрузки) с фактом на ФС: - -- как `worker` реагирует на исчезновение раздачи из qBittorrent - (состояние/пометка загрузки); -- как `undo` защищается, когда источник недоступен — например, - отказываться удалять, если у целевого файла счётчик ссылок == 1 (нет - второй копии) или исходный путь не существует, и явно об этом сообщать. - Откат снимает **лишний** хардлинк, а не последнюю копию файла; -- как ловить пропажу целевых файлов и отражать её в состоянии (напр. - периодическая сверка или проверка при показе — «разложено, но файлов - нет»), чтобы можно было осознанно перепривязать/переразложить. +Осталось (path 2) — продолжение «единого окна»: удалять просмотренное +**из самого jellybit**, не идя руками в qBittorrent/Jellyfin. Нужно +продумать: команду удаления (снять наши хардлинки + опц. удалить раздачу из +qBittorrent с файлами), подтверждение осознанности (а не случайный клик) и +как это сочетается с инвариантом «источник неприкосновенен», когда +пользователь сам просит убрать источник. Связано: [ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md), -[architecture.md](specs/architecture.md) → «Раскладка файлов» (undo, -инвариант источника), [workflow.md](specs/workflow.md) (`done → reverted`). +[architecture.md](specs/architecture.md) → «Раскладка файлов», +[workflow.md](specs/workflow.md). ### Наблюдаемость: метрики и учёт стоимости LLM diff --git a/internal/config/config.go b/internal/config/config.go index 3a72a55..0a625c4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -99,6 +99,10 @@ type Worker struct { PollInterval Duration `toml:"poll_interval"` StuckAfter Duration `toml:"stuck_after"` MagnetTimeout Duration `toml:"magnet_timeout"` + // SourceMissingThreshold — сколько подряд тиков сверки без раздачи в + // qBittorrent нужно, чтобы счесть источник удалённым (дебаунс пропажи, + // см. state-reconciliation). Любое появление раздачи сбрасывает счётчик. + SourceMissingThreshold int `toml:"source_missing_threshold"` } // Recognition — пороги распознавания. @@ -173,9 +177,10 @@ func Default() *Config { }, Jellyfin: Jellyfin{Timeout: Duration(10 * time.Second)}, Worker: Worker{ - PollInterval: Duration(5 * time.Second), - StuckAfter: Duration(time.Hour), - MagnetTimeout: Duration(30 * time.Minute), + PollInterval: Duration(5 * time.Second), + StuckAfter: Duration(time.Hour), + MagnetTimeout: Duration(30 * time.Minute), + SourceMissingThreshold: 3, }, Recognition: Recognition{AutoConfidenceThreshold: 0.85}, HTTP: HTTP{Listen: ":8080"}, @@ -241,6 +246,9 @@ func (c *Config) validate() error { if c.LLM.MaxRetries < 0 { errs = append(errs, fmt.Errorf("llm.max_retries %d must be >= 0", c.LLM.MaxRetries)) } + if c.Worker.SourceMissingThreshold < 1 { + errs = append(errs, fmt.Errorf("worker.source_missing_threshold %d must be >= 1", c.Worker.SourceMissingThreshold)) + } // Обязательные секреты включённых секций (ловит криво отрендеренный деплоем // файл). qBittorrent — ядро, пароль нужен всегда. diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 55f0005..33a5155 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -130,9 +130,10 @@ type downloadView struct { State string Error string Terminal bool - Reviewable bool // review/deferred — есть экран ревью - Undoable bool // done — можно откатить раскладку - Relinkable bool // reverted/cancelled — можно перепривязать заново + Reviewable bool // review/deferred — есть экран ревью + Undoable bool // done — можно откатить раскладку + Relinkable bool // reverted/cancelled/target_missing — можно перепривязать заново + Note string // пояснение рассинхрона (target_missing/orphaned/deleted) } func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) { @@ -317,7 +318,23 @@ func toView(d store.Download) downloadView { Terminal: d.State.IsTerminal(), Reviewable: d.State == store.StateReview || d.State == store.StateDeferred, Undoable: d.State == store.StateDone, - Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled, + Relinkable: d.State == store.StateReverted || d.State == store.StateCancelled || + d.State == store.StateTargetMissing, + Note: desyncNote(d.State), + } +} + +// desyncNote — пояснение состояния рассинхрона для UI (см. state-reconciliation). +func desyncNote(s store.State) string { + switch s { + case store.StateTargetMissing: + return "разложено, но файлов в библиотеке нет — можно привязать заново" + case store.StateOrphaned: + return "источник удалён — это последняя копия данных, откат недоступен" + case store.StateDeleted: + return "удалены и источник, и файлы в библиотеке" + default: + return "" } } diff --git a/internal/layout/layout.go b/internal/layout/layout.go index 49267ea..98d5810 100644 --- a/internal/layout/layout.go +++ b/internal/layout/layout.go @@ -228,6 +228,11 @@ type Result struct { // ErrCollision — цель существует и это другой файл (нужен review). var ErrCollision = errors.New("layout: target collision") +// ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных +// (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не +// единственный файл (см. state-reconciliation, инвариант безопасного Undo). +var ErrLastCopy = errors.New("layout: refusing to remove last remaining copy") + // Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя // доводит начатое. При коллизии (цель занята чужим файлом) возвращает // ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС @@ -367,16 +372,41 @@ func sameFile(src, dst string) (bool, error) { // Undo удаляет ссылки и подчищает опустевшие каталоги. Снимает только пути // строго под библиотеками (источник недосягаем). Отсутствующая цель — не // ошибка (идемпотентно). Возвращает число удалённых ссылок. +// +// Защита от потери данных: сперва предпроверка всего батча — если хоть одна +// существующая цель оказывается последней копией (источник пропал или +// nlink<=1), весь Undo отклоняется с ErrLastCopy и НИ ОДНА ссылка не +// снимается (иначе частичный откат стёр бы часть данных). Так откат снимает +// лишний хардлинк, а не единственный файл. func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) { log := logctx.FromOr(ctx, l.log) + + // Предпроверка: путь под библиотекой + не последняя копия. + for _, ln := range links { + if _, err := undoRoot(l, ln.Dst); err != nil { + return 0, err + } + fi, err := os.Lstat(ln.Dst) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue // цели уже нет — снимать нечего (идемпотентно) + } + return 0, fmt.Errorf("layout: undo stat %q: %w", ln.Dst, err) + } + last, err := isLastCopy(fi, ln.Src) + if err != nil { + return 0, fmt.Errorf("layout: undo check %q: %w", ln.Dst, err) + } + if last { + return 0, fmt.Errorf("%w: %q (источник %q недоступен)", ErrLastCopy, ln.Dst, ln.Src) + } + } + removed := 0 for _, ln := range links { - root := l.movies - if !underRoot(l.movies, ln.Dst) { - root = l.series - } - if !underRoot(root, ln.Dst) { - return removed, fmt.Errorf("layout: undo outside library: %q", ln.Dst) + root, err := undoRoot(l, ln.Dst) + if err != nil { + return removed, err } if err := os.Remove(ln.Dst); err != nil { if errors.Is(err, fs.ErrNotExist) { @@ -392,6 +422,36 @@ func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) { return removed, nil } +// undoRoot возвращает корень библиотеки, под которым лежит dst, либо ошибку, +// если путь не под movies/series (откат трогает только библиотеку). +func undoRoot(l *Layouter, dst string) (string, error) { + root := l.movies + if !underRoot(l.movies, dst) { + root = l.series + } + if !underRoot(root, dst) { + return "", fmt.Errorf("layout: undo outside library: %q", dst) + } + return root, nil +} + +// isLastCopy сообщает, является ли цель последней копией данных: исходный файл +// уже не существует, либо у цели не осталось других жёстких ссылок (nlink<=1). +// В обоих случаях unlink цели уничтожил бы единственную копию. +func isLastCopy(fi os.FileInfo, src string) (bool, error) { + if _, err := os.Lstat(src); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return true, nil // источника нет — цель единственная копия + } + return false, fmt.Errorf("stat source %q: %w", src, err) + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return false, fmt.Errorf("unexpected stat type for %q", fi.Name()) + } + return st.Nlink <= 1, nil +} + // pruneEmptyDirs удаляет опустевшие каталоги вверх до (не включая) root. // Ошибки игнорируются: непустой каталог os.Remove не удалит — это и нужно. func pruneEmptyDirs(dir, root string) { diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go index 6dc8a29..9b8c14a 100644 --- a/internal/layout/layout_test.go +++ b/internal/layout/layout_test.go @@ -232,6 +232,60 @@ func TestUndo_RemovesLinksAndPrunesDirs(t *testing.T) { } } +func TestUndo_RefusesLastCopyWhenSourceGone(t *testing.T) { + f := newFixture(t) + links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020, + Files: []PlanFile{{Src: f.srcFile(t, "m/film.mkv", "data"), Role: RoleMain}}}) + if _, err := f.l.Apply(context.Background(), links); err != nil { + t.Fatal(err) + } + // Источник удалён (как при удалении раздачи из qBittorrent) → цель стала + // последней копией (nlink упал до 1). + if err := os.Remove(links[0].Src); err != nil { + t.Fatal(err) + } + + n, err := f.l.Undo(context.Background(), links) + if !errors.Is(err, ErrLastCopy) { + t.Fatalf("err = %v, want ErrLastCopy", err) + } + if n != 0 { + t.Errorf("removed = %d, want 0 (ничего не сняли)", n) + } + // Цель НЕ удалена — данные целы. + if _, err := os.Stat(links[0].Dst); err != nil { + t.Errorf("target must remain (last copy): %v", err) + } +} + +func TestUndo_RefusesWholeBatchIfAnyLastCopy(t *testing.T) { + f := newFixture(t) + links, _ := f.l.BuildLinks(Plan{Type: Series, Title: "Show", Year: 2021, + Files: []PlanFile{ + {Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(1), Episode: intp(1)}, + {Src: f.srcFile(t, "s/e2.mkv", "2"), Role: RoleEpisode, Season: intp(1), Episode: intp(2)}, + }}) + if _, err := f.l.Apply(context.Background(), links); err != nil { + t.Fatal(err) + } + // Источник второй серии пропал — весь батч должен быть отклонён целиком. + if err := os.Remove(links[1].Src); err != nil { + t.Fatal(err) + } + + n, err := f.l.Undo(context.Background(), links) + if !errors.Is(err, ErrLastCopy) { + t.Fatalf("err = %v, want ErrLastCopy", err) + } + if n != 0 { + t.Errorf("removed = %d, want 0 (батч не трогаем)", n) + } + // Первая ссылка (источник жив) НЕ снята — частичного отката нет. + if _, err := os.Stat(links[0].Dst); err != nil { + t.Errorf("first target must remain (no partial undo): %v", err) + } +} + func TestUndo_Idempotent(t *testing.T) { f := newFixture(t) links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020, diff --git a/internal/store/download.go b/internal/store/download.go index 57721e7..40f2b9a 100644 --- a/internal/store/download.go +++ b/internal/store/download.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "slices" "strings" "time" ) @@ -26,19 +27,34 @@ const ( StateFailed State = "failed" StateCancelled State = "cancelled" StateReverted State = "reverted" // Ф3 + + // Состояния рассинхрона с реальностью (см. state-reconciliation). + StateTargetMissing State = "target_missing" // источник есть, цель удалена → relink + StateOrphaned State = "orphaned" // источник пропал, цель (последняя копия) есть + StateDeleted State = "deleted" // нет ни источника, ни цели ) +// terminalStates — единый список окончательно остановленных состояний: +// источник истины и для IsTerminal, и для выборки «активных» задач +// (FindActiveByInfohash). Любое новое терминальное состояние добавляется +// ТОЛЬКО сюда — иначе семантика «активности» разъедется (idempotency_key +// снимается по IsTerminal, а активность считалась бы по другому списку). +// +// Состояния рассинхрона (target_missing/orphaned/deleted) — терминальны по +// тем же причинам, что reverted/cancelled: дальше двигает либо человек +// (relink из target_missing), либо фоновая сверка (healing/прогрессия), +// напрямую через SetDownloadState; ключ идемпотентности при этом не нужен. +var terminalStates = []State{ + StateDone, StateCancelled, StateFailed, StateReverted, + StateTargetMissing, StateOrphaned, StateDeleted, +} + // IsTerminal сообщает, завершена ли задача окончательно. Для терминальных // состояний снимается ключ идемпотентности — тот же infohash можно завести // заново новой задачей (см. architecture.md, «повторное добавление»). // stuck терминальным не считается: задача восстановима (retry). func (s State) IsTerminal() bool { - switch s { - case StateDone, StateCancelled, StateFailed, StateReverted: - return true - default: - return false - } + return slices.Contains(terminalStates, s) } // SourceType — вид источника загрузки. @@ -61,8 +77,11 @@ type Download struct { State State `db:"state"` ErrorCode sql.NullString `db:"error_code"` ErrorMsg sql.NullString `db:"error_msg"` - CreatedAt string `db:"created_at"` - UpdatedAt string `db:"updated_at"` + // SourceMissCount — счётчик подряд идущих тиков сверки без раздачи в + // qBittorrent (дебаунс пропажи источника, см. state-reconciliation). + SourceMissCount int `db:"source_miss_count"` + CreatedAt string `db:"created_at"` + UpdatedAt string `db:"updated_at"` } // sqliteTimeLayout — формат меток datetime('now') в SQLite (UTC). @@ -141,11 +160,12 @@ func (s *Store) ListDownloadsByState(ctx context.Context, states ...State) ([]Do // FindActiveByInfohash возвращает незавершённую задачу для infohash либо // (nil, nil), если её нет. Основа идемпотентного приёма. func (s *Store) FindActiveByInfohash(ctx context.Context, infohash string) (*Download, error) { - term := []State{StateDone, StateCancelled, StateFailed, StateReverted} - ph := make([]string, len(term)) - args := make([]any, 0, len(term)+1) + // «Активна» = не в терминальном состоянии. Список — единый с IsTerminal + // (terminalStates), иначе семантика активности разъедется с idempotency_key. + ph := make([]string, len(terminalStates)) + args := make([]any, 0, len(terminalStates)+1) args = append(args, infohash) - for i, st := range term { + for i, st := range terminalStates { ph[i] = "?" args = append(args, string(st)) } @@ -205,6 +225,20 @@ WHERE id = ?` return nil } +// SetSourceMissCount записывает счётчик пропусков источника (дебаунс сверки). +// Состояние не трогает — это отдельная от перехода фоновая отметка. +func (s *Store) SetSourceMissCount(ctx context.Context, id int64, n int) error { + res, err := s.DB.ExecContext(ctx, + `UPDATE download SET source_miss_count = ? WHERE id = ?`, n, id) + if err != nil { + return fmt.Errorf("set download %d source_miss_count: %w", id, err) + } + if affected, _ := res.RowsAffected(); affected == 0 { + return fmt.Errorf("set download %d source_miss_count: not found", id) + } + return nil +} + // nullArg возвращает nil для пустой строки (чтобы писать NULL, не ""). func nullArg(s string) any { if s == "" { diff --git a/internal/store/download_test.go b/internal/store/download_test.go index 3fe0e6d..336099f 100644 --- a/internal/store/download_test.go +++ b/internal/store/download_test.go @@ -72,6 +72,31 @@ func TestFindActiveByInfohash(t *testing.T) { } } +// Состояния рассинхрона (target_missing/orphaned/deleted) терминальны: задача +// в них не должна считаться «активной» (иначе relink/ingest-дедуп решат, что +// для infohash уже есть активная задача). Регрессия: FindActiveByInfohash и +// IsTerminal обязаны опираться на один список терминальных состояний. +func TestFindActiveByInfohash_DesyncStatesNotActive(t *testing.T) { + for _, st := range []State{StateTargetMissing, StateOrphaned, StateDeleted} { + t.Run(string(st), func(t *testing.T) { + store := newTestStore(t) + ctx := context.Background() + ih := "33333333333333333333333333333333333333" + string(st[0:2]) + + id, err := store.CreateDownload(ctx, newDownloading(ih)) + if err != nil { + t.Fatal(err) + } + if err := store.SetDownloadState(ctx, id, st, "", ""); err != nil { + t.Fatal(err) + } + if d, err := store.FindActiveByInfohash(ctx, ih); err != nil || d != nil { + t.Fatalf("%s: активной задачи быть не должно, получили (%v,%v)", st, d, err) + } + }) + } +} + // Терминальное состояние снимает ключ идемпотентности и позволяет завести // тот же infohash заново (повторная закачка спустя время). func TestTerminalReleasesInfohash(t *testing.T) { diff --git a/internal/store/migrations/0003_source_miss_count.sql b/internal/store/migrations/0003_source_miss_count.sql new file mode 100644 index 0000000..0bb1e4f --- /dev/null +++ b/internal/store/migrations/0003_source_miss_count.sql @@ -0,0 +1,8 @@ +-- +goose Up +-- Счётчик подряд идущих тиков фоновой сверки без раздачи в qBittorrent. +-- Дебаунс пропажи источника: помечаем orphaned/deleted только при достижении +-- порога [worker].source_missing_threshold (см. state-reconciliation). +ALTER TABLE download ADD COLUMN source_miss_count INTEGER NOT NULL DEFAULT 0; + +-- +goose Down +ALTER TABLE download DROP COLUMN source_miss_count; diff --git a/internal/tgbot/bot.go b/internal/tgbot/bot.go index 7c03975..c9232b7 100644 --- a/internal/tgbot/bot.go +++ b/internal/tgbot/bot.go @@ -246,6 +246,8 @@ func (b *Bot) Notify(ctx context.Context, downloadID int64, event worker.NotifyE switch event { case worker.EventDone: text = b.renderDone(rd) + case worker.EventTargetMissing, worker.EventOrphaned: + text, kb = b.renderDesync(rd, event), b.webOnly(downloadID) default: text, kb = b.renderCard(rd) } diff --git a/internal/tgbot/render.go b/internal/tgbot/render.go index 25ce6fb..bca9a2b 100644 --- a/internal/tgbot/render.go +++ b/internal/tgbot/render.go @@ -95,6 +95,22 @@ func (b *Bot) renderDone(rd *worker.ReviewData) string { return fmt.Sprintf("✅ Готово: «%s» — разложено файлов: %d.", title, n) } +// renderDesync — уведомление о рассинхроне (источник/цель удалены вручную). +func (b *Bot) renderDesync(rd *worker.ReviewData, event worker.NotifyEvent) string { + title := rd.Plan.Title + if title == "" { + title = "#" + itoa(rd.Download.ID) + } + switch event { + case worker.EventTargetMissing: + return fmt.Sprintf("⚠️ «%s»: файлы удалены из библиотеки, источник на месте — можно привязать заново.", title) + case worker.EventOrphaned: + return fmt.Sprintf("⚠️ «%s»: источник удалён из qBittorrent, библиотечная копия осталась последней (откат недоступен).", title) + default: + return fmt.Sprintf("⚠️ «%s»: рассинхрон состояния.", title) + } +} + func (b *Bot) webOnly(id int64) *tgbotapi.InlineKeyboardMarkup { url := b.reviewURL(id) if url == "" { diff --git a/internal/worker/reconcile.go b/internal/worker/reconcile.go new file mode 100644 index 0000000..7aa194d --- /dev/null +++ b/internal/worker/reconcile.go @@ -0,0 +1,196 @@ +package worker + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "git.vakhrushev.me/av/jellybit/internal/layout" + "git.vakhrushev.me/av/jellybit/internal/logctx" + "git.vakhrushev.me/av/jellybit/internal/qbt" + "git.vakhrushev.me/av/jellybit/internal/store" +) + +// desyncStates — состояния, которые ведёт сверка с реальностью: уже +// разложенные (done) и сами состояния рассинхрона. Активные и +// пользовательски-терминальные (reverted/cancelled/failed/stuck) сверка не +// трогает (см. state-reconciliation). +var desyncStates = []store.State{ + store.StateDone, + store.StateTargetMissing, + store.StateOrphaned, + store.StateDeleted, +} + +// deriveState выводит состояние задачи из двумерной матрицы «источник × цель» +// (см. state-reconciliation, D1). +func deriveState(sourcePresent, targetPresent bool) store.State { + switch { + case sourcePresent && targetPresent: + return store.StateDone + case sourcePresent && !targetPresent: + return store.StateTargetMissing + case !sourcePresent && targetPresent: + return store.StateOrphaned + default: + return store.StateDeleted + } +} + +// reconcileDesync сверяет разложенные/desync-задачи с реальностью. Вызывается +// из Poll под w.mu. byHash — карта присутствующих в qBittorrent раздач. +func (w *Worker) reconcileDesync(ctx context.Context, byHash map[string]qbt.Torrent) { + cands, err := w.store.ListDownloadsByState(ctx, desyncStates...) + if err != nil { + w.log.Warn("reconcile list desync failed", "capability", capIngest, "error", err) + return + } + for _, d := range cands { + w.reconcileOneDesync(ctx, d, byHash) + } +} + +// reconcileOneDesync сверяет одну задачу: вычисляет присутствие источника (с +// дебаунсом) и цели, выводит состояние и переходит при изменении. +func (w *Worker) reconcileOneDesync(ctx context.Context, d store.Download, byHash map[string]qbt.Torrent) { + if !d.Infohash.Valid { + return // нечем сопоставить источник + } + ctx = w.scoped(ctx, capIngest, d.ID, d.Infohash.String) + _, sourceSeen := byHash[strings.ToLower(d.Infohash.String)] + + // Дебаунс пропажи источника: считаем удалённым только после порога подряд + // идущих промахов; любое появление сбрасывает счётчик. + sourcePresent := w.debounceSource(ctx, d, sourceSeen) + + targetPresent, err := w.targetPresent(ctx, d.ID) + if err != nil { + logctx.From(ctx).Warn("reconcile target probe failed", "error", err) + return // не можем проверить цель — не дёргаем состояние + } + + want := deriveState(sourcePresent, targetPresent) + if want == d.State { + return + } + w.transition(ctx, d, want, "reconcile", reconcileReason(sourcePresent, targetPresent)) +} + +// debounceSource обновляет счётчик промахов источника и возвращает, считать ли +// источник присутствующим с учётом порога. Запись счётчика — только при его +// изменении (без лишних UPDATE на каждом тике). +func (w *Worker) debounceSource(ctx context.Context, d store.Download, sourceSeen bool) bool { + threshold := max(w.cfg.SourceMissingThreshold, 1) + if sourceSeen { + if d.SourceMissCount != 0 { + if err := w.store.SetSourceMissCount(ctx, d.ID, 0); err != nil { + logctx.From(ctx).Warn("reconcile reset miss count failed", "error", err) + } + } + return true + } + miss := d.SourceMissCount + 1 + if err := w.store.SetSourceMissCount(ctx, d.ID, miss); err != nil { + logctx.From(ctx).Warn("reconcile bump miss count failed", "error", err) + } + // До порога источник трактуется как присутствующий — задача не дёргается. + return miss < threshold +} + +// targetPresent сообщает, существуют ли разложенные хардлинки задачи. Цель +// считается присутствующей, только если существуют ВСЕ ссылки последнего +// батча; частичная пропажа — это отсутствие цели (библиотека сломана → relink). +func (w *Worker) targetPresent(ctx context.Context, id int64) (bool, error) { + batch, err := w.store.LatestBatchID(ctx, id) + if err != nil { + return false, fmt.Errorf("latest batch: %w", err) + } + if batch == "" { + return false, nil // ничего не разложено — цели нет + } + rows, err := w.store.ListFileLinksByBatch(ctx, batch) + if err != nil { + return false, fmt.Errorf("list links: %w", err) + } + laidOut := 0 + for _, r := range rows { + // Все статусы, означающие реальный файл на диске: только что слинкован, + // скопирован (фолбэк) или уже существовал тем же inode (идемпотентный + // повтор apply). Прочие (collision и т.п.) — не наша разложенная цель. + if !isLaidOut(r.Status) { + continue + } + laidOut++ + if _, err := os.Lstat(r.DstPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("stat target %q: %w", r.DstPath, err) + } + } + return laidOut > 0, nil +} + +// isLaidOut сообщает, означает ли статус file_link реально лежащий на ФС файл +// нашей раскладки (а не коллизию или иной не-разложенный исход). +func isLaidOut(status string) bool { + switch layout.LinkStatus(status) { + case layout.StatusLinked, layout.StatusCopied, layout.StatusExists: + return true + default: + return false + } +} + +// reconcileReason — человекочитаемая причина перехода для лога/UI. +func reconcileReason(sourcePresent, targetPresent bool) string { + switch { + case sourcePresent && targetPresent: + return "источник и цель на месте" + case sourcePresent && !targetPresent: + return "цель удалена, источник на месте" + case !sourcePresent && targetPresent: + return "источник удалён, цель (последняя копия) на месте" + default: + return "удалены и источник, и цель" + } +} + +// --- Синхронный preflight перед действием (не доверяем state в БД) --- + +// ensureSourcePresent синхронно (без дебаунса) проверяет, что раздача есть в +// 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) + } + _, ok, err := w.torrentByInfohash(ctx, d.Infohash.String) + if err != nil { + return fmt.Errorf("%s: %w", op, err) + } + if ok { + return nil + } + // Источник пропал — немедленно приводим состояние к реальности. + w.reconcileToReality(ctx, *d, false) + return fmt.Errorf("%s: источник удалён из qBittorrent: %w", op, ErrConflict) +} + +// reconcileToReality выводит и проставляет состояние по уже известному факту об +// источнике (sourcePresent) и фактически проверенной цели. Используется +// preflight-проверками: немедленно, без дебаунса. +func (w *Worker) reconcileToReality(ctx context.Context, d store.Download, sourcePresent bool) { + targetPresent, err := w.targetPresent(ctx, d.ID) + if err != nil { + logctx.FromOr(ctx, w.log).Warn("preflight target probe failed", "error", err) + return + } + want := deriveState(sourcePresent, targetPresent) + if want == d.State { + return + } + w.transition(ctx, d, want, "reconcile", reconcileReason(sourcePresent, targetPresent)) +} diff --git a/internal/worker/reconcile_test.go b/internal/worker/reconcile_test.go new file mode 100644 index 0000000..df1933b --- /dev/null +++ b/internal/worker/reconcile_test.go @@ -0,0 +1,186 @@ +package worker + +import ( + "context" + "os" + "path/filepath" + "testing" + + "git.vakhrushev.me/av/jellybit/internal/qbt" + "git.vakhrushev.me/av/jellybit/internal/store" +) + +// reconcileFixture готовит done-задачу с одной разложенной ссылкой: цель — +// файл в temp-каталоге (создаётся при makeTarget), источник — наличие +// раздачи в fakeQbt (sourcePresent). +type reconcileFixture struct { + w *Worker + st *memStore + dst string +} + +func newReconcileFixture(t *testing.T, state store.State, sourcePresent, makeTarget bool) reconcileFixture { + t.Helper() + dir := t.TempDir() + dst := filepath.Join(dir, "Movie (2024).mkv") + src := filepath.Join(dir, "src.mkv") + if makeTarget { + if err := os.WriteFile(dst, []byte("video"), 0o644); err != nil { + t.Fatal(err) + } + } + + st := newMemStore() + d := completedDownload(1) + d.State = state + st.put(d) + st.links = append(st.links, store.FileLink{ + DownloadID: 1, ApplyBatchID: "b1", SrcPath: src, DstPath: dst, + Kind: "video", Status: "linked", + }) + + var torrents []qbt.Torrent + if sourcePresent { + torrents = []qbt.Torrent{{Hash: ihTest}} + } + w := testWorkerWith(st, &fakeQbt{torrents: torrents}, nil, nil) + w.cfg.SourceMissingThreshold = 1 // помечаем при первой же пропаже (без задержки) + return reconcileFixture{w: w, st: st, dst: dst} +} + +func TestReconcileMatrix(t *testing.T) { + cases := []struct { + name string + source, target bool + want store.State + }{ + {"источник+цель → done", true, true, store.StateDone}, + {"цель удалена → target_missing", true, false, store.StateTargetMissing}, + {"источник удалён → orphaned", false, true, store.StateOrphaned}, + {"оба удалены → deleted", false, false, store.StateDeleted}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newReconcileFixture(t, store.StateDone, tc.source, tc.target) + if err := f.w.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + if got := f.st.downloads[1].State; got != tc.want { + t.Errorf("state = %q, want %q", got, tc.want) + } + }) + } +} + +func TestReconcileHealing(t *testing.T) { + // Источник вернулся, цель на месте → orphaned лечится обратно в done. + f := newReconcileFixture(t, store.StateOrphaned, true, true) + if err := f.w.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + if got := f.st.downloads[1].State; got != store.StateDone { + t.Errorf("state = %q, want done (healing)", got) + } +} + +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, + 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 { + t.Errorf("state = %q, want target_missing (частичная пропажа)", got) + } +} + +func TestReconcileDebounce(t *testing.T) { + // Порог 3: первые два промаха не помечают, третий — помечает orphaned; + // возврат источника лечит обратно и сбрасывает счётчик. + f := newReconcileFixture(t, store.StateDone, false, true) + f.w.cfg.SourceMissingThreshold = 3 + + for i := 1; i <= 2; i++ { + 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 { + t.Fatalf("tick %d: state = %q, want done (до порога)", i, got) + } + 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 { + t.Fatalf("tick 3: state = %q, want orphaned (порог достигнут)", got) + } + + // Источник вернулся. + f.w.qbt.(*fakeQbt).torrents = []qbt.Torrent{{Hash: ihTest}} + 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 { + t.Errorf("state = %q, want done (источник вернулся)", got) + } + if got := f.st.downloads[1].SourceMissCount; got != 0 { + t.Errorf("miss = %d, want 0 (сброс)", got) + } +} + +func TestReconcileSkipsActiveStates(t *testing.T) { + // downloading сверкой не трогаем, даже если раздачи нет в qBittorrent. + f := newReconcileFixture(t, store.StateDownloading, false, true) + if err := f.w.Poll(context.Background()); err != nil { + t.Fatalf("Poll: %v", err) + } + 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) + if err == nil { + t.Fatal("ожидали отказ Undo для orphaned") + } + if got := f.st.downloads[1].State; got != store.StateOrphaned { + t.Errorf("state = %q, want orphaned (без изменений)", got) + } +} + +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 { + t.Fatalf("Relink: %v", err) + } + 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]) + } +} + +func TestPreflightFixesStaleState(t *testing.T) { + // В БД target_missing (источник якобы есть), но фактически источник пропал, + // а цель на месте: relink немедленно приводит состояние к orphaned, не + // дожидаясь фоновой сверки. + f := newReconcileFixture(t, store.StateTargetMissing, false, true) + if err := f.w.Relink(context.Background(), 1); err == nil { + t.Fatal("ожидали отказ relink при пропавшем источнике") + } + if got := f.st.downloads[1].State; got != store.StateOrphaned { + t.Errorf("state = %q, want orphaned (preflight привёл к реальности)", got) + } +} diff --git a/internal/worker/review.go b/internal/worker/review.go index f7f4f9d..bb5a979 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -238,7 +238,10 @@ func (w *Worker) Apply(ctx context.Context, id int64) error { return fmt.Errorf("apply: lookup torrent: %w", err) } if !ok { - return fmt.Errorf("apply: torrent not found") + // Источник исчез между ревью и применением — приводим состояние к + // реальности и отказываем (preflight, не доверяем state в БД). + w.reconcileToReality(ctx, *d, false) + return fmt.Errorf("apply: источник удалён из qBittorrent: %w", ErrConflict) } w.transition(ctx, *d, store.StateLinking, "", "") @@ -306,17 +309,13 @@ func (w *Worker) Relink(ctx context.Context, id int64) error { if err != nil { return fmt.Errorf("relink: %w", err) } - if d.State != store.StateReverted && d.State != store.StateCancelled { - return fmt.Errorf("relink: download %d is in state %s (expected reverted/cancelled): %w", id, d.State, ErrConflict) + 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) } - if !d.Infohash.Valid { - return fmt.Errorf("relink: download %d has no infohash", id) - } - // Раздача должна ещё быть в qBittorrent — без неё распознавать нечего. - if _, ok, terr := w.torrentByInfohash(ctx, d.Infohash.String); terr != nil { - return fmt.Errorf("relink: %w", terr) - } else if !ok { - return fmt.Errorf("relink: торрент не найден в qBittorrent") + // Источник нужен для распознавания — проверяем синхронно (без дебаунса) и при + // его отсутствии приводим состояние к реальности (orphaned/deleted). + if err := w.ensureSourcePresent(ctx, d, "relink"); err != nil { + return err } // Вернуть задачу в активную обработку можно, только если другой активной // задачи на этот infohash нет (partial unique index по idempotency_key). @@ -348,6 +347,9 @@ func (w *Worker) Rerecognize(ctx context.Context, id int64) error { if err != nil { return err } + if err := w.ensureSourcePresent(ctx, d, "rerecognize"); err != nil { + return err + } ctx = w.scoped(ctx, capReview, id, d.Infohash.String) logctx.From(ctx).Info("review re-recognizing without hint") w.transition(ctx, *d, store.StateRecognizing, "", "") @@ -367,6 +369,9 @@ func (w *Worker) Refine(ctx context.Context, id int64, hint string) error { if err != nil { return err } + if err := w.ensureSourcePresent(ctx, d, "refine"); err != nil { + return err + } ctx = w.scoped(ctx, capReview, id, d.Infohash.String) if err := w.store.AddHint(ctx, id, hint); err != nil { return fmt.Errorf("refine: %w", err) @@ -389,6 +394,9 @@ func (w *Worker) SetType(ctx context.Context, id int64, mediaType string) error if err != nil { return err } + if err := w.ensureSourcePresent(ctx, d, "set type"); err != nil { + return err + } ctx = w.scoped(ctx, capReview, id, d.Infohash.String) if err := w.store.SetOverride(ctx, id, ovrMediaType, mediaType); err != nil { return fmt.Errorf("set type: %w", err) @@ -452,7 +460,9 @@ func (w *Worker) Defer(ctx context.Context, id int64) error { } // Undo снимает хардлинки последнего батча и переводит задачу в reverted. -// Источник недосягаем (раскладчик удаляет только пути под библиотекой). +// Источник недосягаем (раскладчик удаляет только пути под библиотекой). Откат +// снимает ЛИШНИЙ хардлинк, а не последнюю копию: layout.Undo отказывается +// удалять ссылку, если источник уже пропал (nlink<=1) — см. state-reconciliation. func (w *Worker) Undo(ctx context.Context, id int64) error { w.mu.Lock() defer w.mu.Unlock() @@ -464,6 +474,11 @@ func (w *Worker) Undo(ctx context.Context, id int64) error { if err != nil { return fmt.Errorf("undo: %w", err) } + if d.State == store.StateOrphaned { + // Источник удалён → библиотечный хардлинк остался единственной копией. + // Откат сотрёт данные насовсем — отказываем явно. + 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) } diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 03028b9..a0d081b 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -155,7 +155,8 @@ func TestRerecognize_ReviewToRecognizing(t *testing.T) { d := completedDownload(1) d.State = store.StateReview st.put(d) - w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} + w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) if err := w.Rerecognize(context.Background(), 1); err != nil { t.Fatalf("Rerecognize: %v", err) @@ -184,8 +185,10 @@ func TestRelink_TorrentMissing(t *testing.T) { if err := w.Relink(context.Background(), 1); err == nil { t.Fatal("ожидали ошибку при отсутствии торрента, получили nil") } - if st.downloads[1].State != store.StateReverted { - t.Errorf("state = %q, want reverted (без изменений)", st.downloads[1].State) + // Preflight приводит состояние к реальности: источника нет и цели нет + // (reverted — ссылки сняты) → deleted (см. state-reconciliation). + if st.downloads[1].State != store.StateDeleted { + t.Errorf("state = %q, want deleted (preflight привёл к реальности)", st.downloads[1].State) } } @@ -291,6 +294,13 @@ func (m *memStore) SetDownloadState(_ context.Context, id int64, st store.State, return nil } +func (m *memStore) SetSourceMissCount(_ context.Context, id int64, n int) error { + if d, ok := m.downloads[id]; ok { + d.SourceMissCount = n + } + return nil +} + func (m *memStore) CreateRecognition(_ context.Context, r *store.Recognition, reasons []string) (int64, error) { for _, e := range m.recs { if e.DownloadID == r.DownloadID { @@ -578,7 +588,8 @@ func TestRefine_AddsHintAndRerecognizes(t *testing.T) { d := completedDownload(1) d.State = store.StateReview st.put(d) - w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} + w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) if err := w.Refine(context.Background(), 1, "это второй сезон"); err != nil { t.Fatalf("Refine: %v", err) @@ -599,7 +610,8 @@ func TestSetType(t *testing.T) { d := completedDownload(1) d.State = store.StateReview st.put(d) - w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + qb := &fakeQbt{torrents: []qbt.Torrent{{Hash: ihTest}}} + w := testWorkerWith(st, qb, &fakeRecognizer{}, nil) if err := w.SetType(context.Background(), 1, "series"); err != nil { t.Fatalf("SetType: %v", err) diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 6f5a157..bc81092 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -40,6 +40,7 @@ type Store interface { ListDownloadsByState(ctx context.Context, states ...store.State) ([]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 // Discovery (усыновление раздач по категории/тегу). ExistsByInfohash(ctx context.Context, infohash string) (bool, error) @@ -88,8 +89,10 @@ type Layouter interface { type NotifyEvent string const ( - EventReview NotifyEvent = "review" // задача ждёт подтверждения - EventDone NotifyEvent = "done" // раскладка завершена + EventReview NotifyEvent = "review" // задача ждёт подтверждения + EventDone NotifyEvent = "done" // раскладка завершена + EventOrphaned NotifyEvent = "orphaned" // источник пропал, цель — последняя копия + EventTargetMissing NotifyEvent = "target_missing" // цель удалена, доступен relink ) // Notifier — исходящие пинги (Telegram). Вызывается неблокирующе. @@ -113,6 +116,9 @@ type Config struct { PollInterval time.Duration StuckAfter time.Duration // stalledDL дольше → stuck MagnetTimeout time.Duration // metaDL дольше → failed + // SourceMissingThreshold — порог дебаунса пропажи источника (тиков сверки). + // <1 трактуется как 1 (помечаем при первой же устойчивой пропаже). + SourceMissingThreshold int } // Worker — поллер и владелец переходов. @@ -235,6 +241,10 @@ func (w *Worker) Poll(ctx context.Context) error { } w.reconcile(ctx, d, t) } + + // Сверка разложенных задач с реальностью (источник в qBit + хардлинки на ФС) + // — отдельно от активных, по двумерной матрице (см. state-reconciliation). + w.reconcileDesync(ctx, byHash) return nil } @@ -293,6 +303,10 @@ func (w *Worker) transition(ctx context.Context, d store.Download, state store.S go w.notifier.Notify(context.Background(), d.ID, EventReview) case store.StateDone: go w.notifier.Notify(context.Background(), d.ID, EventDone) + case store.StateOrphaned: + go w.notifier.Notify(context.Background(), d.ID, EventOrphaned) + case store.StateTargetMissing: + go w.notifier.Notify(context.Background(), d.ID, EventTargetMissing) } } diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 2ed19e1..3996ff8 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -90,6 +90,15 @@ func (f *fakeStore) SetDownloadState(_ context.Context, id int64, st store.State return nil } +func (f *fakeStore) SetSourceMissCount(_ context.Context, id int64, n int) error { + d, ok := f.downloads[id] + if !ok { + return fmt.Errorf("download %d not found", id) + } + d.SourceMissCount = n + return nil +} + // --- Ф3-методы Store (заглушки; переопределяются в review_test.go) --- func (f *fakeStore) CreateRecognition(_ context.Context, _ *store.Recognition, _ []string) (int64, error) { diff --git a/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/.openspec.yaml b/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/.openspec.yaml new file mode 100644 index 0000000..34f9314 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-29 diff --git a/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/design.md b/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/design.md new file mode 100644 index 0000000..7e506ae --- /dev/null +++ b/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/design.md @@ -0,0 +1,204 @@ +## Context + +`worker` ведёт FSM загрузки (см. [workflow.md](../../../docs/specs/workflow.md)). +Сейчас `Poll` сверяет с qBittorrent только задачи в `downloading` +(`ListDownloadsByState(StateDownloading)`); терминальные задачи (`done`) с +реальностью не сверяются вовсе. Если раздача исчезла из qBittorrent, для +активной задачи код лишь пишет `Warn("active download not found")`; для +`done` не делает ничего. + +Удаление просмотренного контента — штатная эксплуатационная операция, и оно +ручное: из qBittorrent (источник + скачанные файлы) или из Jellyfin (наши +хардлинки). Два инварианта при этом ломаются молча: + +- **«Источник неприкосновенен»** перестаёт держаться, когда источника уже + нет: библиотечный хардлинк становится последней копией, а `layout.Undo` + безусловно `unlink`-ает цель. +- **Состояние `done` правдиво** перестаёт держаться, когда файлы убрали из + библиотеки: ссылок нет, а БД числит `done`. + +Данные, на которые опираемся: `download.infohash` (сопоставление с qBit), +`file_link.dst_path` + `status` (разложенные хардлинки), `file_link.src_path` +(исходный файл раздачи). qBittorrent уже листаем целиком в `Poll`. + +## Goals / Non-Goals + +**Goals:** + +- Распознавать ручное удаление источника и/или цели фоновой сверкой и + отражать его в состоянии задачи — **без автоматических действий**. +- Не допускать потери данных при `Undo`, когда источник уже удалён. +- Сделать рассинхрон видимым (состояние в UI + уведомление автору). +- Сохранить самовосстановление: вернулась реальность — вернулось состояние. + +**Non-Goals:** + +- Удаление средствами jellybit (path 2, «единое окно»). +- Автоперезапуск распознавания/раскладки при рассинхроне (relink — вручную). +- Реакция на удаление файлов **внутри** живой раздачи (это `missingFiles`/ + `error` qBittorrent — уже ведёт в `failed`). +- Ретеншн терминальных задач. + +## Decisions + +### D1. Двумерная матрица «источник × цель» → новые состояния FSM + +Рассинхрон параметризуется двумя независимыми фактами. Для задачи, которая +дошла до раскладки, состояние выводится из них: + +| источник (qBit) | цель (хардлинки) | состояние | +|-----------------|------------------|-----------------| +| есть | есть | `done` | +| есть | нет | `target_missing`| +| нет | есть | `orphaned` | +| нет | нет | `deleted` | + +Выбрали **новые значения `state`**, а не флаги поверх состояния: +терминальность и набор доступных команд у этих ситуаций разные, а FSM — +единая точка правды (граф уже в `workflow.md`). Флаги размазали бы логику +«что можно делать» по двум осям. + +- `target_missing` — источник на месте → доступен пользовательский relink + (та же команда «Привязать заново», что из `reverted`/`cancelled`: + `target_missing → recognizing`). Авто-переход в `recognizing` **не** + делаем — это нарушило бы «никаких автодействий». +- `orphaned` — источник пропал, цель (последняя копия) на месте. Команд + вперёд нет; `Undo` заблокирован (см. D4). +- `deleted` — пусто и там, и там; терминально, действий нет. + +**Альтернатива (отклонено):** одно состояние `desynced` + поле-причина. +Хуже: разные исходы требуют разных команд и разной терминальности — проще +развести по состояниям. + +### D2. Какие задачи и как сверяем + +Desync-сверка применяется только к задачам, для которых ожидаем разложенные +файлы и осмысленную связь с источником: `done`, `target_missing`, +`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/ +`deferred`/`linking`) и пользовательски-терминальные (`reverted`/ +`cancelled`/`failed`/`stuck`) сверка **не трогает** (`reverted` = мы сами +сняли ссылки, это не рассинхрон). + +- **Источник присутствует** ⟺ `download.infohash` найден среди торрентов + qBittorrent (карту `byHash` `Poll` уже строит). +- **Цель присутствует** ⟺ все `file_link` со `status = linked` существуют на + ФС (`os.Lstat`). Частичная пропажа (исчезла часть ссылок) трактуется как + «цель отсутствует» → `target_missing` (библиотека сломана, лечится + relink/слиянием). + +Сверку делаем на том же тике `Poll` (5 с). При текущих объёмах (домашний +сервер) `Lstat` по ссылкам терминальных задач дёшев; если объём вырастет +(см. TODO «Ретеншн»), вынесем desync-сверку на отдельный, более редкий +интервал. Состояние выводим и переписываем только при изменении (без +лишних записей и логов на каждом тике). + +### D3. Дебаунс пропажи источника + +qBittorrent может временно пропасть из выдачи (рестарт демона, мигнул API), +тогда как локальный `Lstat` цели надёжен. Поэтому дебаунсим **только +отсутствие источника**: + +- новый столбец `download.source_miss_count` (миграция goose); +- источник отсутствует на тике → `source_miss_count++`; найден → + сбрасываем в `0`; +- источник считается удалённым (для вывода состояния) лишь когда + `source_miss_count >= [worker].source_missing_threshold` (по умолчанию + `3` → ~15 с при интервале 5 с). До порога источник трактуется как + присутствующий — задача не дёргается. + +Отсутствие цели в дебаунсе не нуждается (локальная ФС не «мигает»). + +### D4. Безопасный `Undo`: не снимать последнюю копию + +`layout.Undo` для каждой ссылки перед `unlink`: + +1. `Lstat(dst)` — нет файла → нечего снимать, пропускаем (идемпотентно). +2. Иначе читаем `nlink` цели. `nlink <= 1` означает: это **единственная** + ссылка на inode (источник уже удалён) — `unlink` сотрёт данные. Отказ: + не трогаем файл, копим причину. +3. Доп. явный сигнал: `src_path` не существует → тоже отказ (источника нет). + +Отказ возвращается типизированной ошибкой; задача в `reverted` **не** +переходит, причина показывается пользователю. На командном уровне `Undo` +для задачи в `orphaned` отклоняется сразу (по определению источник удалён → +все ссылки — последние копии); per-file `nlink`-проверка остаётся страховкой +на случай устаревшего состояния. Откат снимает **лишний** хардлинк, а не +последнюю копию. + +### D5. Синхронный preflight перед действием (не доверяем `state` в БД) + +Фоновая сверка (D2/D3) — eventual: она отстаёт на интервал поллинга плюс +дебаунс. Поэтому любая **команда**, которой нужен источник или цель, делает +собственную **синхронную пробу** прямо перед действием, а не полагается на +значение `state`: + +- `relink`/«Распознать заново»/«Уточнить» и `Apply` (раскладка) — требуют + источника (раздача в qBittorrent); +- `Undo` — требует источника (чтобы не снять последнюю копию). + +Чтобы не дублировать логику, выделяем чистый помощник +`probe(download) → (sourcePresent, targetPresent)` и `deriveState(...)`, +которые используют **и** фоновая сверка, **и** preflight — единая точка +правды о том, что есть на диске и как это отображается в состояние. + +Ключевое отличие preflight от фона: **без дебаунса** — это явное действие +пользователя «сейчас», единичная проба. Если qBittorrent в этот момент +недоступен, команда честно отказывает («источник недоступен») — пользователь +повторит. Дебаунс нужен только фону, чтобы не дёргать состояние на +транзиентных пропажах. + +При неуспехе предусловия команда не выполняет действие, прогоняет +`deriveState` (приводя `state` к реальности — напр. `done → orphaned`) и +возвращает пользователю причину. Так команда сама «лечит» устаревшее +состояние, не дожидаясь `worker`. + +### D6. Самовосстановление (healing) + +Состояние всегда выводится из текущей матрицы D1 (с учётом дебаунса D3), а +не «залипает». Если источник вернулся (раздачу добавили заново) или цель +снова на месте — следующая сверка переведёт задачу обратно +(`orphaned/target_missing/deleted → done`). `deleted` не делаем абсорбирующим +ради единообразия; на практике одновременный возврат маловероятен. + +### D7. Видимость + +При переходе в `orphaned`/`target_missing` `worker` шлёт уведомление автору +через существующий `notifier` (новые события `EventOrphaned`/ +`EventTargetMissing`), как для `review`/`done`. Web-UI и Telegram +отображают новые состояния; для `orphaned` кнопка `Undo` скрыта/заблокирована +с пояснением «источник удалён — это последняя копия». + +## Risks / Trade-offs + +- **Ложная пометка при долгом простое qBittorrent** (рестарт дольше + `threshold × poll_interval`) → дебаунс D3 + самовосстановление D6: вернётся + раздача — вернётся `done`. Порог настраивается. +- **Стоимость `Lstat` на каждом тике** при росте числа терминальных задач → + при текущих объёмах пренебрежимо; путь отхода — отдельный редкий интервал + desync-сверки (зафиксировано в D2, делаем при необходимости). +- **`nlink` зависит от ФС/синтаксиса `syscall.Stat_t`** (Linux-таргет, + `CGO_ENABLED=0`, `linux/amd64`) → платформа фиксирована деплоем; copy-fallback + раскладки (не хардлинк) даёт `nlink==1` у легитимной копии — такой `Undo` + тоже корректно откажет (это и есть единственная копия). Приемлемо: лучше + отказать, чем удалить данные. +- **Гонки команда/сверка** → всё под `w.mu` per-download, как и остальные + переходы; новых блокировок не вводим. + +## Migration Plan + +- Миграция goose `0003_*`: `ALTER TABLE download ADD COLUMN + source_miss_count INTEGER NOT NULL DEFAULT 0`. Новые значения `state` — + данных не мигрируют (аддитивно). +- Конфиг: новый ключ `[worker].source_missing_threshold` с дефолтом; старые + конфиги валидны без него. +- Откат: безопасен — состояния перестанут проставляться, существующие + desync-задачи останутся со своим значением `state` (UI покажет как + неизвестное/как есть). Столбец можно не удалять. + +## Open Questions + +- Нужны ли пользователю команды из `orphaned`, кроме как ждать + восстановления (например явное «Забыть»/перевод в `deleted` руками)? На + старте — нет, только пометка; добавим, если будет спрос. +- Порог дебаунса по умолчанию (`3`) — уточнить по реальному времени + рестарта qBittorrent на umbar. diff --git a/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/proposal.md b/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/proposal.md new file mode 100644 index 0000000..a866d1d --- /dev/null +++ b/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/proposal.md @@ -0,0 +1,87 @@ +## Why + +Пришло время удалять просмотренные фильмы/сериалы, чтобы освобождать место +под новые. Удаляют их **вручную** — из qBittorrent (источник) или из +Jellyfin (целевые хардлинки). Сейчас jellybit этого не замечает: `worker` +поллит только задачи в `downloading`, терминальные задачи (`done`) с +реальностью не сверяет. Состояние в БД молча расходится с диском: + +- **Источник пропал, цель осталась.** qBittorrent стирает скачанные файлы + при удалении раздачи. Хардлинк в библиотеке становится **последней** + ссылкой на inode, а обычный `Undo` (`unlink` цели) сотрёт единственную + копию — прямая потеря данных. Инвариант «источник неприкосновенен» + молчаливо перестаёт держаться: источника уже нет. +- **Цель пропала, источник остался.** Файлы убрали из библиотеки, а + jellybit по-прежнему числит загрузку `done` — состояние врёт. + +Цель этого change — **path 1**: научить jellybit корректно **распознавать** +ручное удаление и **отражать** его в состоянии, **не предпринимая +автоматических действий**. Удаление средствами самого jellybit («единое +окно», path 2) — отдельная будущая работа. + +## What Changes + +- **Фоновая сверка с реальностью.** `worker` расширяет периодический поллинг: + помимо `downloading` сверяет терминальные/desync-задачи с фактом на ФС — + присутствие раздачи в qBittorrent (источник) и существование разложенных + хардлинков `file_link.dst_path` (цель). +- **Новые состояния FSM** для рассинхрона (двумерная матрица «источник × цель»): + - `target_missing` — источник на месте, цель удалена: доступен + пользовательский флоу повторной привязки (relink); авто-действий нет. + - `orphaned` — источник пропал, цель на месте: «осиротевшая» раздача, + библиотечный хардлинк — единственная копия. + - `deleted` — пропали и источник, и цель: терминально, действий больше нет. + - Реальность «лечится» сама: если источник/цель снова появились, сверка + возвращает задачу в согласованное состояние. +- **Дебаунс пропажи источника.** Раздача считается удалённой только после + `N` подряд тиков без неё (qBittorrent мог рестартовать / API мигнул); + любое появление сбрасывает счётчик. Порог — в конфиге. +- **Защита `Undo` от потери данных.** `Undo`/`layout.Undo` отказывается + снимать хардлинк, если он **последняя копия** (`nlink == 1`) или исходный + путь не существует — откат снимает лишний хардлинк, а не единственный + файл; причина отказа сообщается явно. +- **Уведомление** автору загрузки при переходе в `orphaned`/`target_missing` + (через существующий `notifier`), чтобы рассинхрон не оставался незаметным. + +Не входит в объём (Non-goals): + +- Удаление раздач/файлов средствами самого jellybit (path 2, «единое окно»). +- Автоматический повторный прогон распознавания/раскладки при рассинхроне — + только пометка состояния; relink инициирует человек. +- Сверка содержимого источника (manual delete файлов **внутри** живой + раздачи → это `error`/`missingFiles` qBittorrent, отдельная тема). +- Ретеншн/авточистка терминальных задач (отдельная задача в TODO). + +## Capabilities + +### New Capabilities + +- `state-reconciliation`: периодическая сверка записанного состояния + загрузки с фактом на ФС (раздача в qBittorrent, разложенные хардлинки), + состояния рассинхрона (`target_missing`/`orphaned`/`deleted`), их переходы + и дебаунс; а также инвариант безопасного `Undo` (не снимать последнюю + копию). + +### Modified Capabilities + + + +## Impact + +- **Спеки:** новая `openspec/specs/state-reconciliation/`; правки живых + `docs/specs/workflow.md` (граф состояний: +`target_missing`/`orphaned`/ + `deleted`, переходы) и `docs/specs/jellyfin-layout.md` (инвариант + безопасного `Undo`). +- **Код:** `internal/worker` (расширение `Poll`/`reconcile` на терминальные + задачи, дебаунс, новые переходы, уведомления), `internal/store` (новые + значения `state`, столбец счётчика промахов, миграция, запросы выборки + desync-задач), `internal/layout` (`Undo` с проверкой `nlink`/наличия + источника), `internal/qbt` (присутствие infohash — уже листаем все + торренты), `internal/httpapi` + web-UI (отображение новых состояний, + блокировка `Undo` для `orphaned`), `internal/config` (`[worker]` порог + дебаунса). +- **Конфиг:** новый ключ в `[worker]` (порог пропусков источника). +- **Совместимость:** новые значения `state` — аддитивно; миграция goose для + столбца счётчика. diff --git a/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/specs/state-reconciliation/spec.md b/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/specs/state-reconciliation/spec.md new file mode 100644 index 0000000..89fea03 --- /dev/null +++ b/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/specs/state-reconciliation/spec.md @@ -0,0 +1,189 @@ +## ADDED Requirements + +### Requirement: Периодическая сверка состояния с реальностью + +`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых +ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и +выводить состояние задачи из двух независимых признаков: присутствия +**источника** (раздача с `download.infohash` в выдаче qBittorrent) и +присутствия **цели** (все `file_link` со `status = linked` существуют на ФС). + +Сверке SHALL подвергаться только состояния `done`, `target_missing`, +`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/ +`deferred`/`linking`) и пользовательски-терминальные (`reverted`/`cancelled`/ +`failed`/`stuck`) состояния сверка трогать SHALL NOT. + +Состояние SHALL переписываться только при его изменении (без записи и логов, +когда выведенное состояние совпадает с текущим). + +#### Scenario: Источник и цель на месте — состояние не меняется + +- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её + разложенные хардлинки существуют +- **THEN** задача остаётся в `done` +- **AND** запись состояния и лог перехода не выполняются + +#### Scenario: Частичная пропажа цели считается отсутствием + +- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте +- **THEN** цель считается отсутствующей и задача переходит в `target_missing` + +### Requirement: Принудительная проверка источника/цели перед действием + +Команда workflow, требующая наличия источника или цели, SHALL синхронно +проверять их присутствие непосредственно перед выполнением действия и SHALL +NOT полагаться только на фоновую сверку `worker` (она отстаёт на интервал +поллинга и дебаунс). Проверка перед действием выполняется как **единичная +немедленная проба без дебаунса**: дебаунс применяется только к фоновому +авто-маркированию. + +Под это требование подпадают как минимум: повторная привязка/распознавание +(`relink`, «Распознать заново», «Уточнить») и раскладка (`Apply`) — требуют +**источника**; `Undo` — требует **источника** (чтобы не снять последнюю +копию). + +Если предусловие не выполнено, система SHALL NOT выполнять действие, SHALL +привести состояние задачи в соответствие с реальностью (вывести состояние из +матрицы «источник × цель», как при сверке) и SHALL сообщить причину +пользователю. + +#### Scenario: Relink проверяет источник перед запуском + +- **WHEN** пользователь даёт команду, требующую источника (например + «Привязать заново») +- **THEN** система синхронно проверяет наличие раздачи в qBittorrent перед + запуском распознавания +- **AND** если источника нет — распознавание не запускается, задача + приводится к `orphaned` либо `deleted` (по наличию цели), причина + сообщается + +#### Scenario: Проверка не ждёт фоновую сверку + +- **WHEN** источник уже удалён, а фоновая сверка ещё не отметила это (в БД + состояние, например, `done`) +- **THEN** команда, требующая источника, немедленно обнаруживает его + отсутствие собственной проверкой и отказывает, не дожидаясь `worker` + +### Requirement: Состояние target_missing и доступность повторной привязки + +Когда у задачи источник присутствует, а цель отсутствует, система SHALL +переводить её в состояние `target_missing` и SHALL NOT предпринимать +автоматических действий (не запускать повторное распознавание/раскладку +самостоятельно). + +Из `target_missing` система SHALL предоставлять пользовательскую команду +повторной привязки (relink) с переходом `target_missing → recognizing`, как +из `reverted`/`cancelled`; повторная привязка идёт через `review` с ручным +подтверждением. + +#### Scenario: Цель удалена, источник на месте + +- **WHEN** сверка обнаруживает, что разложенных хардлинков задачи `done` + больше нет, но раздача в qBittorrent присутствует +- **THEN** задача переходит в `target_missing` +- **AND** система не запускает распознавание или раскладку автоматически + +#### Scenario: Пользователь инициирует повторную привязку + +- **WHEN** для задачи в `target_missing` пользователь даёт команду «Привязать + заново» +- **THEN** задача переходит в `recognizing` и далее проходит через `review` + с ручным подтверждением + +### Requirement: Состояние orphaned при пропаже источника + +Система SHALL переводить задачу в состояние `orphaned`, когда источник +отсутствует (с учётом дебаунса), а цель присутствует, отражая, что +библиотечный хардлинк остался единственной копией данных. + +#### Scenario: Источник удалён, цель на месте + +- **WHEN** сверка устойчиво (после дебаунса) не находит раздачу задачи в + qBittorrent, а её разложенные хардлинки существуют +- **THEN** задача переходит в `orphaned` + +### Requirement: Состояние deleted при пропаже источника и цели + +Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL +переводить задачу в состояние `deleted`. В `deleted` действий над задачей +больше нет. + +#### Scenario: Источник и цель удалены + +- **WHEN** сверка устойчиво не находит раздачу в qBittorrent и разложенных + хардлинков задачи на ФС больше нет +- **THEN** задача переходит в `deleted` + +### Requirement: Дебаунс пропажи источника + +Система SHALL дебаунсить только **отсутствие источника**, чтобы временная +недоступность qBittorrent (рестарт демона, сбой API) не вызывала ложных +пометок: источник считается удалённым лишь после `N` подряд тиков сверки без +него, где `N = [worker].source_missing_threshold`. Любое обнаружение раздачи +SHALL сбрасывать счётчик пропусков. + +Отсутствие цели дебаунсу подвергаться SHALL NOT (локальная проверка ФС +надёжна). + +#### Scenario: Кратковременная пропажа источника не помечается + +- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold` + тиков подряд +- **THEN** источник трактуется как присутствующий и состояние задачи не + меняется + +#### Scenario: Возврат источника сбрасывает счётчик + +- **WHEN** раздача снова обнаружена в qBittorrent +- **THEN** счётчик пропусков источника сбрасывается в ноль + +### Requirement: Самовосстановление состояния при возврате реальности + +Система SHALL возвращать задачу в согласованное состояние, когда реальность +восстановилась (состояние выводится из текущей матрицы «источник × цель»): +при возврате источника и/или цели задача SHALL переходить из +`orphaned`/`target_missing`/`deleted` обратно (в т.ч. в `done`, когда +присутствуют оба). + +#### Scenario: Источник вернулся + +- **WHEN** для задачи в `orphaned` раздача снова появилась в qBittorrent, а + цель по-прежнему на месте +- **THEN** задача возвращается в `done` + +### Requirement: Безопасный Undo не снимает последнюю копию + +`Undo` (снятие созданных хардлинков) SHALL отказываться удалять целевую +ссылку, если она является последней копией данных: целевой файл существует и +его счётчик ссылок `nlink <= 1`, либо исходный файл (`src_path`) не +существует. В этом случае система SHALL NOT выполнять `unlink` такого файла и +SHALL явно сообщать причину отказа; задача в `reverted` при отказе переходить +SHALL NOT. + +Отсутствующую целевую ссылку (файла уже нет) `Undo` SHALL пропускать как +успешно снятую (идемпотентность). Команда `Undo` для задачи в `orphaned` +SHALL отклоняться сразу с пояснением, что источник удалён. + +#### Scenario: Отказ снять единственную копию + +- **WHEN** при `Undo` целевой хардлинк существует, но его `nlink <= 1` (или + исходный файл отсутствует) +- **THEN** система не удаляет файл и сообщает, что это последняя копия +- **AND** задача остаётся в текущем состоянии (не `reverted`) + +#### Scenario: Undo снимает лишний хардлинк при живом источнике + +- **WHEN** при `Undo` целевой хардлинк существует, исходный файл на месте и + `nlink > 1` +- **THEN** система снимает целевой хардлинк, оставляя исходный файл нетронутым + +### Requirement: Уведомление о рассинхроне + +При переходе задачи в `orphaned` или `target_missing` система SHALL +уведомлять автора загрузки через настроенный механизм уведомлений +(`notifier`), чтобы рассинхрон не оставался незамеченным. + +#### Scenario: Уведомление при потере источника + +- **WHEN** задача переходит в `orphaned` +- **THEN** система отправляет автору загрузки уведомление о потере источника diff --git a/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/tasks.md b/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/tasks.md new file mode 100644 index 0000000..37afb1c --- /dev/null +++ b/openspec/changes/archive/2026-06-29-reconcile-removed-source-target/tasks.md @@ -0,0 +1,95 @@ +## 1. Хранилище и состояния + +- [x] 1.1 Добавить значения состояний `target_missing`, `orphaned`, `deleted` + в `internal/store` (константы `State*`) и в перечень допустимых состояний. +- [x] 1.2 Миграция goose `0003_*`: `ALTER TABLE download ADD COLUMN + source_miss_count INTEGER NOT NULL DEFAULT 0`; обновить модель `Download`. +- [x] 1.3 Запросы в `store`: выборка desync-кандидатов + (`done`/`target_missing`/`orphaned`/`deleted`), чтение/сброс/инкремент + `source_miss_count`, чтение `file_link` (`status = linked`) по задаче. + +## 2. Конфигурация + +- [x] 2.1 Добавить `[worker].source_missing_threshold` (int, дефолт `3`) в + `internal/config` с валидацией (`>= 1`); пробросить в `worker.Config`. +- [x] 2.2 Отразить ключ в примере конфига и `docs/conventions/config.md`/ + README, если там перечислены ключи `[worker]`. + +## 3. Сверка с реальностью (worker) + +- [x] 3.1 Выделить общий помощник `probe(download) → (sourcePresent, + targetPresent)` (источник: infohash в qBit; цель: `Lstat` всех + `file_link.dst_path` со `status = linked`, частичная пропажа = цель + отсутствует) и `deriveState(src, tgt) → State` — единая точка правды для + фона и preflight. +- [x] 3.2 В `Poll` для desync-кандидатов вызвать `probe` и реализовать + дебаунс источника: инкремент `source_miss_count` при отсутствии, сброс при + обнаружении; «источник удалён» только при `source_miss_count >= threshold`. +- [x] 3.3 Применить `deriveState` и переходить только при изменении: + `done`/`target_missing`/`orphaned`/`deleted` + healing обратно в `done`. + Переиспользовать `transition`. +- [x] 3.4 Не трогать фоновой сверкой активные и пользовательски-терминальные + состояния (`reverted`/`cancelled`/`failed`/`stuck` и активные). + +## 3a. Синхронный preflight перед действием + +- [x] 3a.1 Перед командами, требующими источника/цели (relink, «Распознать + заново», «Уточнить», `Apply`, `Undo`), вызывать `probe` **без дебаунса** + (единичная немедленная проба), не доверяя `state` в БД. +- [x] 3a.2 При неуспехе предусловия: действие не выполнять, прогнать + `deriveState` (привести `state` к реальности, напр. `done → orphaned`), + вернуть пользователю причину; при недоступности qBittorrent — отказ + «источник недоступен». + +## 4. Безопасный Undo (layout + worker) + +- [x] 4.1 `internal/layout` `Undo`: для каждой ссылки `Lstat(dst)` (нет — + пропустить идемпотентно), иначе проверить `nlink <= 1` и наличие + `src_path`; при «последней копии» — отказ с типизированной ошибкой, без + `unlink`. +- [x] 4.2 `worker.Undo`: отклонять команду для задачи в `orphaned` сразу с + понятным сообщением; при отказе `layout.Undo` — не переводить в `reverted`, + пробросить причину пользователю. +- [x] 4.3 Разрешить переход `target_missing → recognizing` в команде + «Привязать заново» (наряду с `reverted`/`cancelled`). + +## 5. Уведомления + +- [x] 5.1 Добавить события `EventOrphaned`/`EventTargetMissing` и слать + уведомление автору в `transition` при входе в эти состояния (как для + `review`/`done`), неблокирующе и вне `w.mu`. + +## 6. Транспорты (httpapi + web-UI) + +- [x] 6.1 Отобразить новые состояния в списке/карточке загрузки (метки, + пояснения «разложено, но файлов нет» / «источник удалён — последняя копия»). +- [x] 6.2 Скрыть/заблокировать `Undo` для `orphaned`; показать команду + «Привязать заново» для `target_missing`. + +## 7. Тесты + +- [x] 7.1 Таблица переходов сверки: все четыре ячейки матрицы + healing, + частичная пропажа цели → `target_missing`. +- [x] 7.2 Дебаунс: пропажа < порога не помечает; >= порога помечает; возврат + сбрасывает счётчик. +- [x] 7.3 `layout.Undo`: отказ при `nlink <= 1`/отсутствии `src_path`; + снятие лишнего хардлинка при живом источнике; пропуск отсутствующей цели. +- [x] 7.4 `worker.Undo` отклоняется для `orphaned`; relink из + `target_missing` ведёт в `recognizing`. +- [x] 7.5 Preflight: команда с устаревшим `state = done`, но удалённым + источником немедленно отказывает и приводит состояние к `orphaned`/ + `deleted` (не дожидаясь фоновой сверки). + +## 8. Документация и спеки + +- [x] 8.1 Обновить `docs/specs/workflow.md`: граф состояний (+`target_missing`/ + `orphaned`/`deleted`, переходы, healing) и описания. +- [x] 8.2 Обновить `docs/specs/jellyfin-layout.md`: инвариант безопасного + `Undo` (не снимать последнюю копию). +- [x] 8.2a Обновить ER-схему `docs/specs/database.md`: столбец + `download.source_miss_count` + отметка миграции `0003` (конвенция: + схема едет вместе с миграцией). +- [x] 8.3 Снять пункт «Рассинхрон состояния с реальностью» (часть про + detection/marking и undo-guard) из `docs/todo.md` или сузить до path 2. +- [x] 8.4 `openspec validate --strict`; ревью кода; затем `opsx:archive` + (влить дельту `state-reconciliation` в `openspec/specs/`). diff --git a/openspec/specs/state-reconciliation/spec.md b/openspec/specs/state-reconciliation/spec.md new file mode 100644 index 0000000..6e1e2aa --- /dev/null +++ b/openspec/specs/state-reconciliation/spec.md @@ -0,0 +1,201 @@ +# state-reconciliation Specification + +## Purpose + +Сверка записанного состояния загрузки с фактом на файловой системе и в +qBittorrent. Capability описывает периодическую и принудительную проверку +присутствия **источника** (раздача в qBittorrent) и **цели** (разложенные +хардлинки), вывод состояний рассинхрона (`target_missing`/`orphaned`/ +`deleted`) из матрицы «источник × цель», их переходы и самовосстановление, +дебаунс пропажи источника, инвариант безопасного `Undo` (не снимать +последнюю копию) и уведомления о рассинхроне. + +## Requirements + +### Requirement: Периодическая сверка состояния с реальностью + +`worker` SHALL периодически (на тике поллинга) сверять задачи, для которых +ожидаются разложенные файлы, с фактом на файловой системе и в qBittorrent, и +выводить состояние задачи из двух независимых признаков: присутствия +**источника** (раздача с `download.infohash` в выдаче qBittorrent) и +присутствия **цели** (все `file_link` со `status = linked` существуют на ФС). + +Сверке SHALL подвергаться только состояния `done`, `target_missing`, +`orphaned`, `deleted`. Активные (`downloading`/`recognizing`/`review`/ +`deferred`/`linking`) и пользовательски-терминальные (`reverted`/`cancelled`/ +`failed`/`stuck`) состояния сверка трогать SHALL NOT. + +Состояние SHALL переписываться только при его изменении (без записи и логов, +когда выведенное состояние совпадает с текущим). + +#### Scenario: Источник и цель на месте — состояние не меняется + +- **WHEN** для задачи в `done` раздача присутствует в qBittorrent и все её + разложенные хардлинки существуют +- **THEN** задача остаётся в `done` +- **AND** запись состояния и лог перехода не выполняются + +#### Scenario: Частичная пропажа цели считается отсутствием + +- **WHEN** часть разложенных хардлинков задачи удалена, а источник на месте +- **THEN** цель считается отсутствующей и задача переходит в `target_missing` + +### Requirement: Принудительная проверка источника/цели перед действием + +Команда workflow, требующая наличия источника или цели, SHALL синхронно +проверять их присутствие непосредственно перед выполнением действия и SHALL +NOT полагаться только на фоновую сверку `worker` (она отстаёт на интервал +поллинга и дебаунс). Проверка перед действием выполняется как **единичная +немедленная проба без дебаунса**: дебаунс применяется только к фоновому +авто-маркированию. + +Под это требование подпадают как минимум: повторная привязка/распознавание +(`relink`, «Распознать заново», «Уточнить») и раскладка (`Apply`) — требуют +**источника**; `Undo` — требует **источника** (чтобы не снять последнюю +копию). + +Если предусловие не выполнено, система SHALL NOT выполнять действие, SHALL +привести состояние задачи в соответствие с реальностью (вывести состояние из +матрицы «источник × цель», как при сверке) и SHALL сообщить причину +пользователю. + +#### Scenario: Relink проверяет источник перед запуском + +- **WHEN** пользователь даёт команду, требующую источника (например + «Привязать заново») +- **THEN** система синхронно проверяет наличие раздачи в qBittorrent перед + запуском распознавания +- **AND** если источника нет — распознавание не запускается, задача + приводится к `orphaned` либо `deleted` (по наличию цели), причина + сообщается + +#### Scenario: Проверка не ждёт фоновую сверку + +- **WHEN** источник уже удалён, а фоновая сверка ещё не отметила это (в БД + состояние, например, `done`) +- **THEN** команда, требующая источника, немедленно обнаруживает его + отсутствие собственной проверкой и отказывает, не дожидаясь `worker` + +### Requirement: Состояние target_missing и доступность повторной привязки + +Когда у задачи источник присутствует, а цель отсутствует, система SHALL +переводить её в состояние `target_missing` и SHALL NOT предпринимать +автоматических действий (не запускать повторное распознавание/раскладку +самостоятельно). + +Из `target_missing` система SHALL предоставлять пользовательскую команду +повторной привязки (relink) с переходом `target_missing → recognizing`, как +из `reverted`/`cancelled`; повторная привязка идёт через `review` с ручным +подтверждением. + +#### Scenario: Цель удалена, источник на месте + +- **WHEN** сверка обнаруживает, что разложенных хардлинков задачи `done` + больше нет, но раздача в qBittorrent присутствует +- **THEN** задача переходит в `target_missing` +- **AND** система не запускает распознавание или раскладку автоматически + +#### Scenario: Пользователь инициирует повторную привязку + +- **WHEN** для задачи в `target_missing` пользователь даёт команду «Привязать + заново» +- **THEN** задача переходит в `recognizing` и далее проходит через `review` + с ручным подтверждением + +### Requirement: Состояние orphaned при пропаже источника + +Система SHALL переводить задачу в состояние `orphaned`, когда источник +отсутствует (с учётом дебаунса), а цель присутствует, отражая, что +библиотечный хардлинк остался единственной копией данных. + +#### Scenario: Источник удалён, цель на месте + +- **WHEN** сверка устойчиво (после дебаунса) не находит раздачу задачи в + qBittorrent, а её разложенные хардлинки существуют +- **THEN** задача переходит в `orphaned` + +### Requirement: Состояние deleted при пропаже источника и цели + +Когда отсутствуют и источник (с учётом дебаунса), и цель, система SHALL +переводить задачу в состояние `deleted`. В `deleted` действий над задачей +больше нет. + +#### Scenario: Источник и цель удалены + +- **WHEN** сверка устойчиво не находит раздачу в qBittorrent и разложенных + хардлинков задачи на ФС больше нет +- **THEN** задача переходит в `deleted` + +### Requirement: Дебаунс пропажи источника + +Система SHALL дебаунсить только **отсутствие источника**, чтобы временная +недоступность qBittorrent (рестарт демона, сбой API) не вызывала ложных +пометок: источник считается удалённым лишь после `N` подряд тиков сверки без +него, где `N = [worker].source_missing_threshold`. Любое обнаружение раздачи +SHALL сбрасывать счётчик пропусков. + +Отсутствие цели дебаунсу подвергаться SHALL NOT (локальная проверка ФС +надёжна). + +#### Scenario: Кратковременная пропажа источника не помечается + +- **WHEN** раздача отсутствует в qBittorrent меньше `source_missing_threshold` + тиков подряд +- **THEN** источник трактуется как присутствующий и состояние задачи не + меняется + +#### Scenario: Возврат источника сбрасывает счётчик + +- **WHEN** раздача снова обнаружена в qBittorrent +- **THEN** счётчик пропусков источника сбрасывается в ноль + +### Requirement: Самовосстановление состояния при возврате реальности + +Система SHALL возвращать задачу в согласованное состояние, когда реальность +восстановилась (состояние выводится из текущей матрицы «источник × цель»): +при возврате источника и/или цели задача SHALL переходить из +`orphaned`/`target_missing`/`deleted` обратно (в т.ч. в `done`, когда +присутствуют оба). + +#### Scenario: Источник вернулся + +- **WHEN** для задачи в `orphaned` раздача снова появилась в qBittorrent, а + цель по-прежнему на месте +- **THEN** задача возвращается в `done` + +### Requirement: Безопасный Undo не снимает последнюю копию + +`Undo` (снятие созданных хардлинков) SHALL отказываться удалять целевую +ссылку, если она является последней копией данных: целевой файл существует и +его счётчик ссылок `nlink <= 1`, либо исходный файл (`src_path`) не +существует. В этом случае система SHALL NOT выполнять `unlink` такого файла и +SHALL явно сообщать причину отказа; задача в `reverted` при отказе переходить +SHALL NOT. + +Отсутствующую целевую ссылку (файла уже нет) `Undo` SHALL пропускать как +успешно снятую (идемпотентность). Команда `Undo` для задачи в `orphaned` +SHALL отклоняться сразу с пояснением, что источник удалён. + +#### Scenario: Отказ снять единственную копию + +- **WHEN** при `Undo` целевой хардлинк существует, но его `nlink <= 1` (или + исходный файл отсутствует) +- **THEN** система не удаляет файл и сообщает, что это последняя копия +- **AND** задача остаётся в текущем состоянии (не `reverted`) + +#### Scenario: Undo снимает лишний хардлинк при живом источнике + +- **WHEN** при `Undo` целевой хардлинк существует, исходный файл на месте и + `nlink > 1` +- **THEN** система снимает целевой хардлинк, оставляя исходный файл нетронутым + +### Requirement: Уведомление о рассинхроне + +При переходе задачи в `orphaned` или `target_missing` система SHALL +уведомлять автора загрузки через настроенный механизм уведомлений +(`notifier`), чтобы рассинхрон не оставался незамеченным. + +#### Scenario: Уведомление при потере источника + +- **WHEN** задача переходит в `orphaned` +- **THEN** система отправляет автору загрузки уведомление о потере источника diff --git a/web/templates/index.html b/web/templates/index.html index 4885fe4..8d5662b 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -28,6 +28,9 @@ .state-failed { background: #e74c3c55; } .state-cancelled { background: #95a5a655; } .state-reverted { background: #95a5a655; } + .state-target_missing { background: #e67e2255; } + .state-orphaned { background: #e74c3c88; } + .state-deleted { background: #95a5a655; } .actions { display: flex; gap: .4rem; flex-wrap: wrap; } a.button { display: inline-block; padding: .35rem .6rem; border: 1px solid #8886; border-radius: .3rem; text-decoration: none; } small { color: #8888; } @@ -57,6 +60,7 @@ {{.Context}} {{.State}} + {{if .Note}}
{{.Note}}{{end}} {{if .Error}}
{{.Error}}{{end}}