## 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.