# Жизненный цикл загрузки и машина состояний > **Источник истины переехал в 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` (полное удаление, см. ниже). - **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) → «Пути и контейнеры».