Files
jellybit/docs/specs/workflow.md
T
avandClaude Opus 4.8 6b7c090ce4 Владение целевым путём при повторной раскладке (state-reconciliation)
Завершённая загрузка ложно «воскресала» из 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>
2026-06-29 18:10:05 +03:00

11 KiB
Raw Blame History

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

Как загрузка проходит путь от приёма источника до разложенных файлов: состояния, переходы и то, что их вызывает. Кто владеет переходами и общее устройство — в 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 / 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 (убрать созданные ссылки).
  • 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_timeoutfailed; stalledDL дольше stuck_afterstuck (восстановимо ретраем). Возраст считаем от создания задачи.
  • ошибка: error/missingFilesfailed.

Пути файлов берём из API (save_path + относительные имена из /torrents/files, уже включающие корневую папку торрента), не из константы (обычно это уже хост-путь). «Incomplete»-каталог в qBittorrent включён (/srv/media/incomplete): пока качается — файлы там, по завершении qBit переносит их в /srv/media/downloads (состояние moving — дожидаемся окончания переноса и только потом берём финальный путь). Подробнее о путях и песочнице — architecture.md → «Пути и контейнеры».