Скан Jellyfin (POST /Library/Refresh) слался только при входе в done.
После Undo (reverted) и Delete (deleted) наши хардлинки сняты, а Jellyfin
держал битые записи до скана по расписанию.
Гейт скана в едином чекпоинте transitionErr переведён с state == done на
предикат triggersScan(state) по множеству {done, reverted, deleted}: гейт по
состоянию-цели естественно ловит пользовательские Undo/Delete и
reconcile-производный deleted, идемпотентно. target_missing/orphaned —
промежуточный рассинхрон (ждём relink/лечения) — исключены.
OpenSpec: заведена и влита дельта file-layout (требование
«Пересканирование Jellyfin после изменения библиотечных ссылок»); change
архивирован. Синк рукописных доков architecture.md/workflow.md. Тесты:
скан стреляет на reverted и deleted, молчит на входе вне множества.
Закрыта задача беклога jellyfin-skan-posle-udaleniya.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
Жизненный цикл загрузки и машина состояний
Источник истины переехал в OpenSpec. Прямой путь FSM (downloading → completed → stuck/failed, поллинг, усыновление) —
openspec/specs/ download-tracking/; сверка с реальностью —openspec/specs/ state-reconciliation/; уведомления —openspec/specs/notifications/. Этот файл — справочный нарратив по графу состояний; при расхождении верна спека OpenSpec.
Как загрузка проходит путь от приёма источника до разложенных файлов: состояния, переходы и то, что их вызывает. Кто владеет переходами и общее устройство — в 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
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 → «Транспорты». - 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(убрать созданные ссылки) и Удалить →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 → «Владение целевым путём»).
Без этого правила переиспользование пути ложно «воскрешало» бы удалённую
задачу в 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_codeqbit_error) — это настоящий провал, в отличие от таймаута. - источник пропал: раздача активной загрузки устойчиво (после дебаунса
source_missing_threshold, тот же счётчик, что и сверка рассинхрона) исчезла из qBittorrent (удалил пользователь/другой клиент) →failed(error_codesource_gone). Иначеdownloadingбез раздачи оставался бы вечным зомби, которого никто не двигает (MAJOR-3). В отличие от таймаутов, сверкаsource_goneне воскрешает (удаление намеренно) — но задача штатно retriable:Retryзаново отдаёт сохранённый источник.
Уведомление и восстановление
- Любой переход в
failed/stuckуведомляет автора загрузки (notifier), чтобы падение не оставалось незамеченным — включая приёмное падениеqbit_add(не удалось добавить в qBittorrent), которое идёт мимо поллинг-цикла. Повторные падения одной задачи в пределах окна дебаунса уведомляют лишь раз — чтобы мерцающийstalled-торрент (stuck↔downloading) не спамил. failed/stuckиз-за нашей нетерпеливости (error_codemagnet_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
→ «Пути и контейнеры».