# Жизненный цикл загрузки и машина состояний

> **Источник истины переехал в OpenSpec.** Прямой путь FSM (downloading →
> completed → stuck/failed, поллинг, усыновление) — `openspec/specs/
> download-tracking/`; сверка с реальностью — `openspec/specs/
> state-reconciliation/`; уведомления — `openspec/specs/notifications/`. Этот
> файл — справочный нарратив по графу состояний; при расхождении верна спека
> OpenSpec.

Как загрузка проходит путь от приёма источника до разложенных файлов:
состояния, переходы и то, что их вызывает. Кто владеет переходами и общее
устройство — в [architecture.md](architecture.md); детали распознавания —
в [recognition.md](recognition.md); действия человека в ревью — в
[review-ux.md](review-ux.md).

## Граф состояний

```mermaid
stateDiagram-v2
    [*] --> downloading: ingest (источник отдан в qBittorrent)

    downloading --> completed: файлы на месте
    downloading --> stuck: stalledDL дольше stuck_after
    downloading --> failed: metaDL дольше magnet_timeout (страховка) / error
    downloading --> failed: источник пропал из qBittorrent (source_gone, после дебаунса)

    completed --> recognizing

    recognizing --> linking: авто (матч в базе + валидация)
    recognizing --> review: нужно подтверждение / ответ LLM не разобран

    review --> linking: Применить
    review --> recognizing: Уточнить / Распознать заново
    review --> deferred: Позже
    review --> cancelled: Отклонить
    deferred --> review: любое действие (та же поверхность)

    linking --> done
    linking --> review: коллизия цели
    linking --> failed: ошибка ФС

    done --> reverted: Undo

    reverted --> recognizing: Привязать заново
    cancelled --> recognizing: Привязать заново

    stuck --> downloading: Retry / сверка (раздача ожила)
    failed --> downloading: Retry / сверка (метаданные пришли)
    failed --> completed: сверка (торрент уже готов)
    stuck --> completed: сверка (торрент уже готов)

    done --> target_missing: сверка — цель удалена
    done --> orphaned: сверка — источник пропал
    done --> deleted: Удалить (delete)
    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
        В cancelled ведут: «Отклонить»
        (из нетерминальных) и «Закрыть»
        (стоп-кран — из любого состояния,
        кроме deleted; только статус)
    end note
```

Условно-терминальные состояния — `done`, `cancelled`, `failed`,
`reverted`: задача в них останавливается, но из `failed`/`stuck` есть
**Retry**, а из `reverted`/`cancelled` — **Привязать заново**. `stuck`
восстановимо ретраем.

## Состояния и переходы

- **ingest → downloading** — приняли источник + контекст, отдали в
  qBittorrent (категория `qbittorrent.category`), записали в БД с ключом
  идемпотентности. См. [architecture.md](architecture.md) → «Транспорты».
- **downloading / completed** — `worker` поллит qBittorrent
  (`worker.poll_interval`, 5 с). Готовность — только когда файлы на месте
  (не `moving`/`checking*`), см. «Завершение в qBittorrent» ниже.
- **recognizing** — `recognize` строит план и оценку уверенности
  ([recognition.md](recognition.md)). Невалидный/непарсящийся ответ LLM →
  review (не failed).
- **review** — план уходит человеку ([review-ux.md](review-ux.md)); цикл
  `review ⇄ recognizing` — перераспознавание по подсказке. «Уточнить» —
  подсказка + перераспознавание; «Распознать заново» — повторный прогон
  без новой подсказки, по уже накопленному контексту и подсказкам.
- **deferred** — «Позже» паркует задачу; принимает те же команды, что и
  `review`, и возвращается в поверхность ревью по любому действию.
- **linking** — `layout` создаёт хардлинки; идемпотентно, батчем. Коллизия
  цели возвращает в review, ошибка ФС → failed. См.
  [architecture.md](architecture.md) → «Раскладка файлов».
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
  (опц., см. [architecture.md](architecture.md) → «Пересканирование
  Jellyfin»); доступен **Undo** → `reverted` (убрать созданные ссылки) и
  **Удалить** → `deleted` (полное удаление, см. ниже). Скан дёргается и при
  входе в `reverted`/`deleted` — наши ссылки там сняты, Jellyfin не должен
  держать битые пути.
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
  (ретраибельна); «Отклонить».
- **reverted / cancelled → recognizing** — «Привязать заново»: после
  отката или отклонения можно перезапустить распознавание для той же
  раздачи. Перепривязка всегда идёт через review с ручным подтверждением
  (авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в
  qBittorrent.

## Сверка с реальностью (рассинхрон)

Состояние в БД может разойтись с диском при **ручном** удалении: раздачу
стирают из qBittorrent (источник) или файлы убирают из Jellyfin (целевые
хардлинки). `worker` периодически сверяет уже разложенные задачи с фактом по
двумерной матрице «источник × цель» (источник = раздача в qBittorrent,
цель = разложенные хардлинки на ФС) и выводит состояние:

- **target_missing** — источник на месте, цель удалена. Доступна команда
  «Привязать заново» (`→ recognizing`); авто-действий нет.
- **orphaned** — источник пропал, цель (последняя копия данных) на месте.
  Команд вперёд нет; `Undo` запрещён (снял бы единственную копию).
- **deleted** — нет ни источника, ни цели; **терминально**: сверка его
  больше не переоценивает (см. ниже).

**Undo vs Удалить (delete).** Это разные пользовательские операции. **Undo**
(из `done`) — «перераспознать»: снимает только наши библиотечные ссылки, раздачу
в qBittorrent бережёт, гард последней копии включён (не сотрёт единственный
файл) → `reverted`. **Удалить** (из `done`, `orphaned`, `target_missing`) —
«убрать окончательно, освободить место»: снимает наши ссылки **и** сносит раздачу
с файлами из qBittorrent, гард последней копии осознанно выключен (обход
инварианта «источник неприкосновенен» — только по подтверждению) →
терминальный `deleted`. Идемпотентно к отсутствующей стороне, так что подчищает
остатки из любого из трёх состояний. Инициатор в `deleted` различается по
`error_code`: пользовательское удаление — `user_delete`, вывод сверкой —
`reconcile`. Полные требования — `openspec/specs/state-reconciliation/`.

**Закрыть (dismiss).** Универсальный стоп-кран из любого состояния, кроме
`deleted`: переводит запись в терминальный `cancelled` (`error_code =
"user_dismiss"`), **только меняя статус** — ни файлы (библиотечные хардлинки
`done`/`orphaned` остаются на месте), ни раздачу в qBittorrent не трогает, в
отличие от «Удалить». Служит закрытием зависшей/спорной/лишней записи (напр.
дубля-близнеца в `target_missing`); из `cancelled` дальше доступна перепривязка.
Для нетерминальных ту же роль штатно играет «Отменить» — в UI стоп-кран
показывается там, где иного выхода нет (терминальные, кроме `deleted`).

Сверка трогает только `done`/`target_missing`/`orphaned` — терминальный
`deleted`, активные и пользовательски-терминальные (`reverted`/`cancelled`/
`failed`/`stuck`) состояния не задевает. Реальность «лечится» сама: при
возврате источника/цели задача переходит обратно (вплоть до `done`) — но
**не из `deleted`**: к терминальной задаче источник не вернётся
(идемпотентность снимается только для активных), а её бывший целевой путь, если
его заняла другая загрузка, отбирается переходом владения (см.
[jellyfin-layout.md](jellyfin-layout.md) → «Владение целевым путём»).
Без этого правила переиспользование пути ложно «воскрешало» бы удалённую
задачу в `orphaned`. Пропажа
**источника** дебаунсится (`[worker].source_missing_threshold` подряд идущих
тиков), пропажа цели проверяется немедленно (локальная ФС надёжна). Команды,
которым нужен источник (relink/распознать/применить/undo), проверяют его
**синхронно перед действием** и не полагаются на фоновую сверку. Полные
требования — `openspec/specs/state-reconciliation/`.

Все переходы и команды идут через `worker` под per-download блокировкой —
два транспорта не гонятся за одно состояние. Состояние персистентно в
SQLite; `worker` периодически сверяет qBittorrent с БД и **усыновляет**
раздачи с нашей категорией (`qbittorrent.category`) **или** тегом
(`qbittorrent.tag`), которых ещё нет в БД, заводя для них задачу в
состоянии `downloading`. Категория ставится на добавляемые нами раздачи
(push, задаёт savepath); тег позволяет подхватить уже существующую
раздачу, не трогая её категорию и файлы (pull).

## Завершение в qBittorrent

`worker` опрашивает qBittorrent и сопоставляет его состояния с нашими:

- **готово к раскладке:** `uploading`/`stalledUP`/`pausedUP`/`stoppedUP`/
  `queuedUP`/`forcedUP` (имена `paused*`/`stopped*` различаются между qBit
  v4 и v5 — поддержаны оба).
- **переходное, ждём:** `moving`/`checkingUP`/`checkingResumeData`/
  `allocating` — остаёмся в `downloading`, пока qBit не закончит перенос/
  проверку (готовность не объявляем, даже если флаги «UP»).
- **ещё качается:** `downloading`/`stalledDL`/`metaDL`/`forcedMetaDL`/
  `queuedDL`/`checkingDL`/`forcedDL`/`pausedDL`/`stoppedDL`.
- **застряло по таймауту (страховка):** `metaDL`/`forcedMetaDL` дольше
  `magnet_timeout` → `failed`; `stalledDL` **простаивающий** дольше
  `stuck_after` → `stuck`. `magnet_timeout` — **редкий страховочный
  предохранитель** (дефолт `24h`), а не рабочий механизм: долгий `metaDL`
  (медленные трекеры/мало пиров) — это норма, его не убиваем агрессивно. Меры у
  двух таймаутов **разные**: `magnet_timeout` мерит **возраст** торрента от
  добавления в qBittorrent (`added_on`, фолбэк `created_at`); `stuck_after`
  мерит **длительность простоя** — от `last_activity` (последнее движение
  данных), а не возраст, иначе долго качавшийся торрент, на миг зашедший в
  `stalledDL`, ложно уходит в `stuck` со «stalled for 5h». Оба базиса
  приподнимаются до `retried_at` — ручной retry сбрасывает отсчёт, чтобы возврат
  в `downloading` не ронял задачу снова на ближайшем тике.
- **ошибка:** `error`/`missingFiles` → `failed` (`error_code` `qbit_error`) —
  это настоящий провал, в отличие от таймаута.
- **источник пропал:** раздача активной загрузки устойчиво (после дебаунса
  `source_missing_threshold`, тот же счётчик, что и сверка рассинхрона) исчезла
  из qBittorrent (удалил пользователь/другой клиент) → `failed` (`error_code`
  `source_gone`). Иначе `downloading` без раздачи оставался бы вечным зомби,
  которого никто не двигает (MAJOR-3). В отличие от таймаутов, сверка
  `source_gone` **не воскрешает** (удаление намеренно) — но задача штатно
  retriable: `Retry` заново отдаёт сохранённый источник.

### Уведомление и восстановление

- Любой переход в `failed`/`stuck` **уведомляет** автора загрузки
  (`notifier`), чтобы падение не оставалось незамеченным — включая приёмное
  падение `qbit_add` (не удалось добавить в qBittorrent), которое идёт мимо
  поллинг-цикла. Повторные падения одной задачи в пределах окна дебаунса
  уведомляют лишь раз — чтобы мерцающий `stalled`-торрент
  (`stuck`↔`downloading`) не спамил.
- `failed`/`stuck` из-за нашей нетерпеливости (`error_code` `magnet_timeout`/
  `stalled`) **не тупик**: фоновая сверка возвращает задачу в поток, как
  только источник в qBittorrent ожил и продвинулся за условие падения
  (получил метаданные → `downloading`; уже готов → `completed`). Пока торрент
  всё ещё в `metaDL`/`stalledDL`, задача остаётся упавшей (без зацикливания).
  Настоящие провалы (`qbit_error`) и намеренная пропажа источника
  (`source_gone`) сверкой не воскрешаются — только ручной retry.
- Дополнительно доступен **ручной retry** из веб-UI и Telegram (не только
  REST): возвращает в `downloading`, перецепляясь к живому **здоровому** торренту
  без повторного `Add` (к сломанному — `error`/`missingFiles` — не
  перецепляемся, повторно отдаём источник) и сбрасывая базис таймаутов
  (`retried_at`), чтобы задача не упала снова на ближайшем тике.

Пути файлов берём из API (`save_path` + относительные имена из
`/torrents/files`, уже включающие корневую папку торрента), не из
константы (обычно это уже хост-путь). «Incomplete»-каталог в
qBittorrent **включён** (`/srv/media/incomplete`): пока качается — файлы
там, по завершении qBit переносит их в `/srv/media/downloads` (состояние
`moving` — дожидаемся окончания переноса и только потом берём финальный
путь). Подробнее о путях и песочнице — [architecture.md](architecture.md)
→ «Пути и контейнеры».
