Files
jellybit/docs/specs/workflow.md
T
avandClaude Opus 4.8 1639ebfdd7 Пересканирование Jellyfin: расширить триггер на reverted и deleted
Скан 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>
2026-07-17 21:14:18 +03:00

19 KiB
Raw Blame History

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

Источник истины переехал в 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 / completedworker поллит qBittorrent (worker.poll_interval, 5 с). Готовность — только когда файлы на месте (не moving/checking*), см. «Завершение в qBittorrent» ниже.
  • recognizingrecognize строит план и оценку уверенности (recognition.md). Невалидный/непарсящийся ответ LLM → review (не failed).
  • review — план уходит человеку (review-ux.md); цикл review ⇄ recognizing — перераспознавание по подсказке. «Уточнить» — подсказка + перераспознавание; «Распознать заново» — повторный прогон без новой подсказки, по уже накопленному контексту и подсказкам.
  • deferred — «Позже» паркует задачу; принимает те же команды, что и review, и возвращается в поверхность ревью по любому действию.
  • linkinglayout создаёт хардлинки; идемпотентно, батчем. Коллизия цели возвращает в review, ошибка ФС → failed. См. architecture.md → «Раскладка файлов».
  • done — при входе неблокирующе дёргаем пересканирование Jellyfin (опц., см. architecture.md → «Пересканирование Jellyfin»); доступен Undoreverted (убрать созданные ссылки) и Удалить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_timeoutfailed; stalledDL простаивающий дольше stuck_afterstuck. 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/missingFilesfailed (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-торрент (stuckdownloading) не спамил.
  • 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 → «Пути и контейнеры».