Завершённая загрузка ложно «воскресала» из deleted в orphaned, когда её целевой путь переиспользовала другая загрузка (повторная закачка того же фильма в другом качестве): сверка проверяла лишь существование пути, не проверяя, что файл по нему — наша раскладка. Вводим инвариант «один целевой путь — один владелец»: - при успешной раскладке на освободившийся чужой путь владение переходит к новой загрузке — прежние file_link на этот путь помечаются статусом superseded и перестают считаться целью при сверке; - deleted исключён из desyncStates — терминальное состояние больше не переоценивается (источник к нему не вернётся из-за идемпотентности, цель отбирается переходом владения); - Undo снимает только реально свои разложенные ссылки (superseded пропускает — файл по пути теперь чужой хардлинк); - ошибку перехода владения трактуем как некритичную (WARN-and-continue): файлы уже разложены, рассинхрон чужих задач исправит следующий тик. Без миграции схемы (status — TEXT). Дельта влита в основную спеку, обновлены workflow.md и jellyfin-layout.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 KiB
Жизненный цикл загрузки и машина состояний
Как загрузка проходит путь от приёма источника до разложенных файлов: состояния, переходы и то, что их вызывает. Кто владеет переходами и общее устройство — в architecture.md; детали распознавания — в recognition.md; действия человека в ревью — в review-ux.md.
Граф состояний
stateDiagram-v2
[*] --> downloading: ingest (источник отдан в qBittorrent)
downloading --> completed: файлы на месте
downloading --> stuck: stalledDL дольше stuck_after
downloading --> failed: metaDL дольше magnet_timeout / error
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
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
«Отклонить» доступно из любого
нетерминального состояния
end note
Условно-терминальные состояния — done, cancelled, failed,
reverted: задача в них останавливается, но из failed/stuck есть
Retry, а из reverted/cancelled — Привязать заново. stuck
восстановимо ретраем.
Состояния и переходы
- ingest → downloading — приняли источник + контекст, отдали в
qBittorrent (категория
qbittorrent.category), записали в БД с ключом идемпотентности. См. architecture.md → «Транспорты». - downloading / completed —
workerполлит qBittorrent (worker.poll_interval, 5 с). Готовность — только когда файлы на месте (неmoving/checking*), см. «Завершение в qBittorrent» ниже. - recognizing —
recognizeстроит план и оценку уверенности (recognition.md). Невалидный/непарсящийся ответ LLM → review (не failed). - review — план уходит человеку (review-ux.md); цикл
review ⇄ recognizing— перераспознавание по подсказке. «Уточнить» — подсказка + перераспознавание; «Распознать заново» — повторный прогон без новой подсказки, по уже накопленному контексту и подсказкам. - deferred — «Позже» паркует задачу; принимает те же команды, что и
review, и возвращается в поверхность ревью по любому действию. - linking —
layoutсоздаёт хардлинки; идемпотентно, батчем. Коллизия цели возвращает в review, ошибка ФС → failed. См. architecture.md → «Раскладка файлов». - done — при входе неблокирующе дёргаем пересканирование Jellyfin
(опц., см. architecture.md → «Пересканирование
Jellyfin»); доступен Undo →
reverted(убрать созданные ссылки). - stuck / failed / cancelled — не качается дольше таймаута; ошибка (ретраибельна); «Отклонить».
- reverted / cancelled → recognizing — «Привязать заново»: после отката или отклонения можно перезапустить распознавание для той же раздачи. Перепривязка всегда идёт через review с ручным подтверждением (авто-раскладку не делаем) и требует, чтобы раздача всё ещё была в qBittorrent.
Сверка с реальностью (рассинхрон)
Состояние в БД может разойтись с диском при ручном удалении: раздачу
стирают из qBittorrent (источник) или файлы убирают из Jellyfin (целевые
хардлинки). worker периодически сверяет уже разложенные задачи с фактом по
двумерной матрице «источник × цель» (источник = раздача в qBittorrent,
цель = разложенные хардлинки на ФС) и выводит состояние:
- target_missing — источник на месте, цель удалена. Доступна команда
«Привязать заново» (
→ recognizing); авто-действий нет. - orphaned — источник пропал, цель (последняя копия данных) на месте.
Команд вперёд нет;
Undoзапрещён (снял бы единственную копию). - deleted — нет ни источника, ни цели; терминально: сверка его больше не переоценивает (см. ниже).
Сверка трогает только done/target_missing/orphaned — терминальный
deleted, активные и пользовательски-терминальные (reverted/cancelled/
failed/stuck) состояния не задевает. Реальность «лечится» сама: при
возврате источника/цели задача переходит обратно (вплоть до done) — но
не из deleted: к терминальной задаче источник не вернётся
(идемпотентность снимается только для активных), а её бывший целевой путь, если
его заняла другая загрузка, отбирается переходом владения (см.
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(восстановимо ретраем). Возраст считаем от создания задачи. - ошибка:
error/missingFiles→failed.
Пути файлов берём из API (save_path + относительные имена из
/torrents/files, уже включающие корневую папку торрента), не из
константы (обычно это уже хост-путь). «Incomplete»-каталог в
qBittorrent включён (/srv/media/incomplete): пока качается — файлы
там, по завершении qBit переносит их в /srv/media/downloads (состояние
moving — дожидаемся окончания переноса и только потом берём финальный
путь). Подробнее о путях и песочнице — architecture.md
→ «Пути и контейнеры».