Вторая половина «единого окна»: команда «Удалить» снимает наши библиотечные хардлинки (гард последней копии осознанно выключен, в отличие от Undo) и сносит раздачу с файлами из qBittorrent (deleteFiles=true) → терминальный deleted. Доступна из done/orphaned/target_missing, идемпотентна к отсутствующей стороне; инициатор различается через error_code=user_delete. Подтверждение обязательно: веб — danger-секция внизу страницы (hx-confirm + details), Telegram — двухшаговый inline-confirm. qbt.Delete + layout.Remove (unlink без ErrLastCopy, только свои ссылки под movies/series). Граф переходов не менялся — рёбра уже были. OpenSpec: state-reconciliation +1 требование; синк workflow.md; беклог закрыт. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
225 lines
18 KiB
Markdown
225 lines
18 KiB
Markdown
# Жизненный цикл загрузки и машина состояний
|
||
|
||
> **Источник истины переехал в 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
|
||
«Отклонить» доступно из любого
|
||
нетерминального состояния
|
||
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/`.
|
||
|
||
Сверка трогает только `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)
|
||
→ «Пути и контейнеры».
|