Пересканирование 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>
This commit is contained in:
av
2026-07-17 21:14:18 +03:00
co-authored by Claude Opus 4.8
parent 0354a8c96b
commit 1639ebfdd7
10 changed files with 303 additions and 72 deletions
-1
View File
@@ -30,7 +30,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог
- [[идея] Сложные сериальные раздачи: все сезоны разом, паки, спецраскладки](slozhnye-serialnye-razdachi.md) — ИДЕЯ (проработать крайние случаи)
- [Аниме с абсолютной нумерацией](anime-absolyutnaya-numeraciya.md) — Релизы аниме часто нумеруют серии сквозным числом (#137) без сезонов, а Jellyfin ждёт…
- [Бэкап SQLite](backup-sqlite.md) — architecture
- [Сигнал Jellyfin после отката и удаления файлов](jellyfin-skan-posle-udaleniya.md) — Скан шлётся только на done; после reverted/deleted Jellyfin держит битые записи. Клиент и гейт готовы, но скана нет в openspec-спеках
- [Режиссёр в блоке «Распознано как» на странице загрузки](rezhisser-v-kartochke-zagruzki.md) — На /download/{id} режиссёр всегда прочерк (поля нет в шаблоне); экран ревью его уже выводит
- [Глубокий healthcheck и статус зависимостей](healthcheck-zavisimosti.md) — /healthz проверяет только сам сервис
- [НФТ: масштаб до 100 одновременных загрузок (потолок — 1000)](masshtab-100-zagruzok.md) — Зафиксировать в НФТ ориентир 100/1000 загрузок + аудит узких мест (SQLite, воркер, поллинг)
@@ -1,59 +0,0 @@
# Сигнал Jellyfin после отката и удаления файлов
**Приоритет:** средний
Сейчас пересканирование Jellyfin шлётся **только** при входе в `done`. После
отката (`reverted`) или удаления (`deleted`) хардлинки сняты, а Jellyfin
продолжает показывать записи с битыми путями до следующего скана по расписанию.
Интеграция уже есть целиком, клиент писать не надо:
- `internal/jellyfin/jellyfin.go` — клиент, `RefreshLibraries()` (`:76`) →
`POST /Library/Refresh`. Тесты — `internal/jellyfin/jellyfin_test.go`.
- Конфиг: `internal/config/config.go:99-107` (`Jellyfin{Enabled,URL,APIKey,Proxy,Timeout}`),
валидация `:305-308`, пример `config.example.toml:59-64`.
- Проводка: `cmd/jellybit/serve.go:137-152``wrk.SetScanner(jf)`; интерфейс
`internal/worker/worker.go:171-176`.
Точка правки — гейт в `transitionErr` (`internal/worker/worker.go:846-852`):
`if w.scanner != nil && state == store.StateDone`. Оба пути удаления уже проходят
через ту же `transition`: `Undo()` (`internal/worker/review.go:535``:574`) и
`Delete()` (`:601``:657`). То есть база правки = расширить условие; фоновый
ctx, `capFileLayout`-скоуп и неблокирующая горутина переиспользуются как есть.
## Что учесть
1. **`StateDeleted` приходит не только от пользователя.**
`internal/worker/reconcile.go:30-41` `deriveState()` возвращает `StateDeleted`
при `!sourcePresent && !targetPresent` (авто-сверка). Скан там формально уместен,
но это уже не «после удаления файлов нами». Решить: гейтить по состоянию (просто,
ловит и reconcile) или по факту снятия ссылок (точнее — в `Undo`/`Delete` есть
счётчик снятого, но тогда триггер уезжает из единого чекпоинта `transitionErr`).
2. **`StateTargetMissing`** (`reconcile.go:35`) — цель пропала мимо нас. Кандидат
по той же логике, надо явно решить, входит или нет.
3. **`Dismiss` идёт мимо чекпоинта:** `internal/worker/worker.go:944` пишет
состояние напрямую через `w.store.SetDownloadState`, минуя `transitionErr`. Для
dismiss это корректно (файлы не трогаются), но если вешать скан на `cancelled`
не сработает.
4. `Delete()` при ошибке qBittorrent (`internal/worker/review.go:650-652`)
возвращается **до** `transition` → ссылки сняты, скана не будет. Идемпотентный
повтор дожмёт, но окно рассинхрона есть.
5. Порядок верный: `layouter.Undo` отрабатывает до `transition`, так что скан
увидит уже снятые ссылки.
## Спеки — здесь дыра
Про Jellyfin-скан в `openspec/specs/` **нет ни слова** (грепом
`Library/Refresh|RefreshLibraries|пересканир` — ноль попаданий). Живёт только в
рукописных доках: `docs/specs/architecture.md:182-196` («Пересканирование
Jellyfin», прямо сказано «После успешной раскладки (вход в `done`)»),
`docs/specs/workflow.md:100-102`.
→ Задача тянет дельту в `file-layout` (пакет `jellyfin` отнесён к этой capability —
`architecture.md:44`, код скоупится `capFileLayout`) + правку
`docs/specs/architecture.md:182` и `docs/specs/workflow.md:100`, где формулировка
«при входе в done» станет неверной.
Тесты: `internal/worker/review_test.go:88` `TestScanner_FiresOnDone` +
`recordingScanner` (`:81-86`). Негативных тестов «не стреляет на других состояниях»
нет → расширение безопасно, но тесты на `reverted`/`deleted` надо дописать.
+12 -7
View File
@@ -181,10 +181,15 @@ Jellyfin ([jellyfin-layout.md](jellyfin-layout.md)). Правила:
## Пересканирование Jellyfin
После успешной раскладки (вход в `done`) `worker` неблокирующе просит Jellyfin
пересканировать медиатеку, чтобы новые файлы быстрее появились в проигрывателе.
Включается конфигом `[jellyfin]` (по умолчанию выключено); без него скан не
дёргается.
Когда наши библиотечные хардлинки меняются, `worker` неблокирующе просит Jellyfin
пересканировать медиатеку, чтобы плеер не держал битые пути и быстрее подхватил
новые файлы. Триггерят входы в `done` (файлы разложены), `reverted` (Undo снял
ссылки) и `deleted` (Delete снял ссылки / сверка констатировала их отсутствие) —
гейт по состоянию-цели в едином чекпоинте перехода, поэтому ловит и
пользовательские Undo/Delete, и reconcile-производный `deleted`. Промежуточный
рассинхрон (`target_missing`/`orphaned`) не сканируем — задача ждёт
relink/лечения. Включается конфигом `[jellyfin]` (по умолчанию выключено); без
него скан не дёргается.
- **Один вызов — `POST /Library/Refresh`** (скан всех библиотек). Скан
инкрементальный, поэтому полный дёшев; точечный скан конкретной папки не
@@ -260,9 +265,9 @@ Dockerfile .dockerignore config.example.toml
задач (повторная закачка спустя время → новая задача).
- Состояние — на persistent-томе `/srv/applications/jellybit/data`.
- Детект завершения — поллинг; webhook — на будущее (drafts/ideas).
- Пересканирование Jellyfin после раскладки`POST /Library/Refresh` (скан
всех библиотек, инкрементальный), неблокирующе на входе в `done`; опц.,
включается `[jellyfin]`.
- Пересканирование Jellyfin при изменении наших ссылок`POST /Library/Refresh`
(скан всех библиотек, инкрементальный), неблокирующе на входе в `done`/
`reverted`/`deleted`; опц., включается `[jellyfin]`.
- Источник (magnet/URL/.torrent) отдаём в qBittorrent — без SSRF.
- Авто-раскладка требует подтверждённого матча в базе; иначе review.
- Веб-UI в v1 без авторизации (доверенная LAN, опц. allowlist подсетей).
+3 -1
View File
@@ -100,7 +100,9 @@ stateDiagram-v2
- **done** — при входе неблокирующе дёргаем пересканирование Jellyfin
(опц., см. [architecture.md](architecture.md) → «Пересканирование
Jellyfin»); доступен **Undo**`reverted` (убрать созданные ссылки) и
**Удалить**`deleted` (полное удаление, см. ниже).
**Удалить**`deleted` (полное удаление, см. ниже). Скан дёргается и при
входе в `reverted`/`deleted` — наши ссылки там сняты, Jellyfin не должен
держать битые пути.
- **stuck / failed / cancelled** — не качается дольше таймаута; ошибка
(ретраибельна); «Отклонить».
- **reverted / cancelled → recognizing** — «Привязать заново»: после