From 1639ebfdd7a453ae7bd2dc56b6e76070fc7dccc2 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Fri, 17 Jul 2026 21:14:18 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20Jellyf?= =?UTF-8?q?in:=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?=D1=82=D1=80=D0=B8=D0=B3=D0=B3=D0=B5=D1=80=20=D0=BD=D0=B0=20rev?= =?UTF-8?q?erted=20=D0=B8=20deleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Скан 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) --- docs/backlog/README.md | 1 - docs/backlog/jellyfin-skan-posle-udaleniya.md | 59 ----------------- docs/specs/architecture.md | 19 ++++-- docs/specs/workflow.md | 4 +- internal/worker/review_test.go | 64 +++++++++++++++++++ internal/worker/worker.go | 26 ++++++-- .../proposal.md | 57 +++++++++++++++++ .../specs/file-layout/spec.md | 56 ++++++++++++++++ .../tasks.md | 34 ++++++++++ openspec/specs/file-layout/spec.md | 55 ++++++++++++++++ 10 files changed, 303 insertions(+), 72 deletions(-) delete mode 100644 docs/backlog/jellyfin-skan-posle-udaleniya.md create mode 100644 openspec/changes/archive/2026-07-17-2026-07-17-jellyfin-scan-on-revert-delete/proposal.md create mode 100644 openspec/changes/archive/2026-07-17-2026-07-17-jellyfin-scan-on-revert-delete/specs/file-layout/spec.md create mode 100644 openspec/changes/archive/2026-07-17-2026-07-17-jellyfin-scan-on-revert-delete/tasks.md diff --git a/docs/backlog/README.md b/docs/backlog/README.md index c603012..b85ec4f 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -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, воркер, поллинг) diff --git a/docs/backlog/jellyfin-skan-posle-udaleniya.md b/docs/backlog/jellyfin-skan-posle-udaleniya.md deleted file mode 100644 index 725a460..0000000 --- a/docs/backlog/jellyfin-skan-posle-udaleniya.md +++ /dev/null @@ -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` надо дописать. diff --git a/docs/specs/architecture.md b/docs/specs/architecture.md index 487d546..80d16d5 100644 --- a/docs/specs/architecture.md +++ b/docs/specs/architecture.md @@ -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 подсетей). diff --git a/docs/specs/workflow.md b/docs/specs/workflow.md index 885c78d..6a1e7ec 100644 --- a/docs/specs/workflow.md +++ b/docs/specs/workflow.md @@ -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** — «Привязать заново»: после diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 5375485..eb588ed 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -100,6 +100,70 @@ func TestScanner_FiresOnDone(t *testing.T) { } } +// waitScan ждёт вызова пересканирования Jellyfin. +func waitScan(t *testing.T, s *recordingScanner, when string) { + t.Helper() + select { + case <-s.ch: + case <-time.After(2 * time.Second): + t.Fatalf("пересканирование Jellyfin %s не запустилось", when) + } +} + +// TestScanner_FiresOnReverted — после Undo наши библиотечные хардлинки сняты, вход +// в reverted тоже дёргает пересканирование (Jellyfin не держит битые пути). +func TestScanner_FiresOnReverted(t *testing.T) { + f := newApplyFixture(t, seriesResult().Plan) + if err := f.w.Apply(context.Background(), "1"); err != nil { + t.Fatalf("Apply: %v", err) + } + // Скан подключаем ПОСЛЕ раскладки, чтобы поймать именно вход в reverted. + s := &recordingScanner{ch: make(chan struct{}, 4)} + f.w.SetScanner(s) + + if err := f.w.Undo(context.Background(), "1"); err != nil { + t.Fatalf("Undo: %v", err) + } + waitScan(t, s, "после Undo") +} + +// TestScanner_FiresOnDeleted — после Delete наши ссылки сняты, вход в deleted +// дёргает пересканирование. +func TestScanner_FiresOnDeleted(t *testing.T) { + f := newApplyFixture(t, seriesResult().Plan) + if err := f.w.Apply(context.Background(), "1"); err != nil { + t.Fatalf("Apply: %v", err) + } + s := &recordingScanner{ch: make(chan struct{}, 4)} + f.w.SetScanner(s) + + if err := f.w.Delete(context.Background(), "1"); err != nil { + t.Fatalf("Delete: %v", err) + } + waitScan(t, s, "после Delete") +} + +// TestScanner_SilentOnNonLinkChange — вход, не меняющий наши библиотечные ссылки +// (deferred), скан не дёргает: гейт только по {done, reverted, deleted}. +func TestScanner_SilentOnNonLinkChange(t *testing.T) { + st := newMemStore() + d := completedDownload("1") + d.State = store.StateReview + st.put(d) + w := testWorkerWith(st, &fakeQbt{}, &fakeRecognizer{}, nil) + s := &recordingScanner{ch: make(chan struct{}, 4)} + w.SetScanner(s) + + if err := w.Defer(context.Background(), "1"); err != nil { + t.Fatalf("Defer: %v", err) + } + select { + case <-s.ch: + t.Fatal("скан не должен дёргаться на входе в deferred") + case <-time.After(200 * time.Millisecond): + } +} + func revertedDownload(id string) *store.Download { d := completedDownload(id) d.State = store.StateReverted diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 9f84bda..6a76724 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -836,10 +836,14 @@ func (w *Worker) transitionErr(ctx context.Context, d store.Download, state stor } } - // Раскладка завершена — просим Jellyfin пересканировать библиотеку, чтобы - // новые файлы быстрее появились в проигрывателе. Тоже неблокирующе и вне - // w.mu; недоступность Jellyfin не влияет на состояние задачи. - if w.scanner != nil && state == store.StateDone { + // Наши библиотечные хардлинки изменились — просим Jellyfin пересканировать + // библиотеку, чтобы плеер не держал битые пути и быстрее подхватил новые + // файлы. Триггерят входы, где раскладка «улеглась»: done (ссылки разложены), + // reverted (Undo снял ссылки), deleted (Delete снял / сверка констатировала + // отсутствие). target_missing/orphaned — промежуточный рассинхрон, ждём + // relink/лечения, не сканируем. Неблокирующе и вне w.mu; недоступность + // Jellyfin не влияет на состояние задачи. + if w.scanner != nil && triggersScan(state) { // Скан Jellyfin — неблокирующе и вне w.mu, в фоновом ctx со scoped-логгером // (download_id для корреляции ext.*-записи клиента). Недоступность Jellyfin // на задачу не влияет; ошибку вызова логирует сам клиент (ext.*), здесь гасим. @@ -849,6 +853,20 @@ func (w *Worker) transitionErr(ctx context.Context, d store.Download, state stor return nil } +// triggersScan сообщает, стоит ли на входе в state дёргать пересканирование +// Jellyfin: наши библиотечные хардлинки только что изменились. Гейт по +// состоянию-цели в едином чекпоинте ловит и пользовательские Undo/Delete, и +// reconcile-производный deleted (инициатор роли не играет); target_missing/ +// orphaned — промежуточный рассинхрон (ждём relink/лечения) — исключены. +func triggersScan(state store.State) bool { + switch state { + case store.StateDone, store.StateReverted, store.StateDeleted: + return true + default: + return false + } +} + // shouldNotifyFail дебаунсит повторные уведомления о падении одной задачи // (мерцающий stalled-торрент: stuck↔downloading), чтобы не спамить. Вызывается // под w.mu. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы diff --git a/openspec/changes/archive/2026-07-17-2026-07-17-jellyfin-scan-on-revert-delete/proposal.md b/openspec/changes/archive/2026-07-17-2026-07-17-jellyfin-scan-on-revert-delete/proposal.md new file mode 100644 index 0000000..0cbb2d2 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-2026-07-17-jellyfin-scan-on-revert-delete/proposal.md @@ -0,0 +1,57 @@ +## Why + +Пересканирование Jellyfin сейчас дёргается **только** при входе в `done` (после +успешной раскладки). Но наши библиотечные хардлинки меняются ещё в двух случаях: +**Undo** (`done → reverted`) и **Delete** (`… → deleted`) снимают ссылки. После них +Jellyfin продолжает показывать записи с битыми путями до следующего скана по +расписанию — рассинхрон видимого каталога с реальностью, который мы уже умеем +чинить, но не сигналим. + +Интеграция готова целиком (`internal/jellyfin`, конфиг `[jellyfin]`, проводка +`SetScanner`) — не хватает лишь расширить условие срабатывания. Точка правки — +единый чекпоинт `transitionErr` (`internal/worker/worker.go`), через который уже +проходят оба пути снятия ссылок (`Undo`, `Delete`) и reconcile-производный +`deleted`. + +## What Changes + +- Расширить гейт пересканирования Jellyfin в `transitionErr` с + `state == done` на множество состояний **входа**, где наши библиотечные + хардлинки только что изменились: `done` (файлы разложены), `reverted` (Undo снял + ссылки), `deleted` (Delete снял ссылки / сверка констатировала их отсутствие). +- Гейт **по состоянию-цели** в едином чекпоинте: он естественно ловит и + пользовательские Undo/Delete, и reconcile-производный `deleted` — это + задумано и идемпотентно (лишний скан безвреден, инкрементальный скан дёшев). +- `target_missing` (и `orphaned`) в множество **не** включаем: это промежуточные + состояния рассинхрона, где раскладка ещё не «улеглась» — источник жив, задача + ждёт relink/восстановления и может залечиться обратно в `done`. Скан там + откладываем, чтобы не слать его на каждое колебание сверки; когда задача + придёт в `done`/`deleted`, скан сработает по общему правилу. +- Зафиксировать поведение в спеке `file-layout` (сейчас про Jellyfin-скан в + `openspec/specs/` нет ни слова) и поправить рукописные доки, где формулировка + «при входе в `done`» стала неверной. + +## Capabilities + +### New Capabilities + + +### Modified Capabilities +- `file-layout`: фиксируется триггер пересканирования Jellyfin — не только после + раскладки (`done`), но и после снятия наших библиотечных хардлинков + (`reverted`, `deleted`), неблокирующе и опционально (`[jellyfin]`). + +## Impact + +- Код воркера: `internal/worker/worker.go` — расширить условие скана в + `transitionErr` (`state == done` → множество `{done, reverted, deleted}`), + обновить поясняющий комментарий. +- Тесты: `internal/worker/review_test.go` — позитивные тесты, что скан стреляет + на `reverted` (после `Undo`) и `deleted` (после `Delete`); при желании + негативный (скан не стреляет на входе, не меняющем наши ссылки). +- Доки: `docs/specs/architecture.md` («Пересканирование Jellyfin»), + `docs/specs/workflow.md` — формулировку «при входе в `done`» заменить на + «после раскладки и после снятия наших ссылок (Undo/Delete)». +- Данные/инварианты: не затрагиваются. Скан по-прежнему неблокирующий, вне + `w.mu`, в фоновом ctx; недоступность Jellyfin на состояние задачи не влияет. + Источник неприкосновенен — скан лишь читает библиотеку. diff --git a/openspec/changes/archive/2026-07-17-2026-07-17-jellyfin-scan-on-revert-delete/specs/file-layout/spec.md b/openspec/changes/archive/2026-07-17-2026-07-17-jellyfin-scan-on-revert-delete/specs/file-layout/spec.md new file mode 100644 index 0000000..57da967 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-2026-07-17-jellyfin-scan-on-revert-delete/specs/file-layout/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Пересканирование Jellyfin после изменения библиотечных ссылок + +При сконфигурированном пересканировании Jellyfin (секция `[jellyfin]` включена) система SHALL при входе задачи в одно из состояний множества `{done, reverted, deleted}` **неблокирующе** просить Jellyfin пересканировать медиатеку (`POST /Library/Refresh`, скан всех библиотек). Эти три состояния — точки, где раскладка задачи **улеглась** так, что видимый Jellyfin каталог мог рассинхронизироваться с диском: `done` — наши хардлинки разложены (или восстановлены сверкой); `reverted` — Undo снял наши ссылки; `deleted` — ссылки сняты (Delete) либо констатировано их отсутствие (сверка), задача терминальна. + +Условие срабатывания система SHALL проверять **по состоянию-цели перехода** в +едином чекпоинте записи состояния. Такой гейт SHALL естественно покрывать как +пользовательские команды (Undo → `reverted`, Delete → `deleted`), так и +reconcile-производный `deleted` — инициатор перехода роли не играет; повторный/ +лишний скан безвреден (инкрементальный скан дёшев, операция идемпотентна). + +Состояния **вне** этого множества система сканировать SHALL NOT. Сюда входят как +входы, не меняющие наши ссылки (`review`, `linking`, `cancelled` через Dismiss), +так и **промежуточные состояния рассинхрона** `target_missing` и `orphaned`: там +раскладка ещё не улеглась — задача ждёт relink/восстановления и может +«залечиться» обратно в `done`, поэтому скан на них система откладывает, а не шлёт +на каждое колебание сверки. `target_missing` система не сканирует сознательно, +хотя цель там пропала: это внешняя пропажа при живом источнике, не наше снятие. + +Скан система SHALL выполнять **вне** блокировки воркера, в фоновом контексте и в +отдельной горутине, со scoped-логгером задачи для корреляции. Недоступность +Jellyfin на состояние задачи влиять SHALL NOT — ошибка вызова лишь логируется +(её пишет клиент Jellyfin как запись внешнего вызова). Если пересканирование не +сконфигурировано (`[jellyfin]` выключено), скан не дёргается ни в одном из этих +переходов. + +#### Scenario: Скан после раскладки + +- **GIVEN** пересканирование Jellyfin включено +- **WHEN** задача входит в `done` после успешной раскладки хардлинков +- **THEN** система неблокирующе дёргает `POST /Library/Refresh` + +#### Scenario: Скан после отката (Undo) + +- **GIVEN** пересканирование Jellyfin включено, задача в `done` с разложенными ссылками +- **WHEN** пользователь выполняет Undo и задача входит в `reverted` (наши ссылки сняты) +- **THEN** система неблокирующе дёргает `POST /Library/Refresh` + +#### Scenario: Скан после удаления (Delete) + +- **GIVEN** пересканирование Jellyfin включено, задача в `done` +- **WHEN** пользователь выполняет Delete и задача входит в `deleted` (наши ссылки сняты) +- **THEN** система неблокирующе дёргает `POST /Library/Refresh` + +#### Scenario: Без конфигурации Jellyfin скан не дёргается + +- **GIVEN** пересканирование Jellyfin выключено (`[jellyfin]` не сконфигурировано) +- **WHEN** задача входит в `done`, `reverted` или `deleted` +- **THEN** система скан не дёргает + +#### Scenario: Вход вне множества не сканирует + +- **GIVEN** пересканирование Jellyfin включено +- **WHEN** задача входит в состояние вне `{done, reverted, deleted}` (например, `review` или промежуточный `target_missing`) +- **THEN** система скан не дёргает diff --git a/openspec/changes/archive/2026-07-17-2026-07-17-jellyfin-scan-on-revert-delete/tasks.md b/openspec/changes/archive/2026-07-17-2026-07-17-jellyfin-scan-on-revert-delete/tasks.md new file mode 100644 index 0000000..bf7cb70 --- /dev/null +++ b/openspec/changes/archive/2026-07-17-2026-07-17-jellyfin-scan-on-revert-delete/tasks.md @@ -0,0 +1,34 @@ +## 1. worker — расширить гейт скана + +- [x] 1.1 В `internal/worker/worker.go` (`transitionErr`) заменить условие + `state == store.StateDone` на проверку принадлежности `state` множеству + `{done, reverted, deleted}` (небольшой предикат-хелпер для читаемости). + Остальную механику скана (фоновый ctx, `capFileLayout`-scoped-логгер, + неблокирующая горутина, `w.scanner != nil`) переиспользовать как есть. +- [x] 1.2 Обновить поясняющий комментарий: скан не только «раскладка завершена», + а «наши библиотечные хардлинки изменились» (разложены при `done`, сняты при + `reverted`/`deleted`). + +## 2. Тесты + +- [x] 2.1 `internal/worker/review_test.go`: тест, что скан стреляет на входе в + `reverted` (после `Undo`). +- [x] 2.2 Тест, что скан стреляет на входе в `deleted` (после `Delete`). +- [x] 2.3 (Опц.) Негативный точечный тест: на входе, не меняющем наши ссылки, + скан не дёргается. + +## 3. Доки + +- [x] 3.1 `docs/specs/architecture.md` («Пересканирование Jellyfin» и строка + «Решённые вопросы»): формулировку «при входе в `done`» заменить на «после + раскладки (`done`) и после снятия наших ссылок (`reverted`/`deleted`)». +- [x] 3.2 `docs/specs/workflow.md`: в описании `done` и снятия ссылок отразить, + что скан дёргается и после Undo/Delete. + +## 4. Ревью и сверка + +- [x] 4.1 `task test` и `task lint` зелёные. +- [x] 4.2 Ревью кода (второй чекпоинт) перед archive. +- [x] 4.3 `openspec validate 2026-07-17-jellyfin-scan-on-revert-delete --strict` зелёный. +- [x] 4.4 Синк дельты в `openspec/specs/file-layout`, архив change; удалить + `docs/backlog/jellyfin-skan-posle-udaleniya.md` и строку из индекса беклога. diff --git a/openspec/specs/file-layout/spec.md b/openspec/specs/file-layout/spec.md index 146da0f..dd3bd0b 100644 --- a/openspec/specs/file-layout/spec.md +++ b/openspec/specs/file-layout/spec.md @@ -228,3 +228,58 @@ recognition(is_current)` плюс проверка существования п - **WHEN** строится план раскладки новой загрузки с этим матчем - **THEN** раскладка не выполняется, задача переходит в `review` с причиной рассинхрона папок тайтла +### Requirement: Пересканирование Jellyfin после изменения библиотечных ссылок + +При сконфигурированном пересканировании Jellyfin (секция `[jellyfin]` включена) система SHALL при входе задачи в одно из состояний множества `{done, reverted, deleted}` **неблокирующе** просить Jellyfin пересканировать медиатеку (`POST /Library/Refresh`, скан всех библиотек). Эти три состояния — точки, где раскладка задачи **улеглась** так, что видимый Jellyfin каталог мог рассинхронизироваться с диском: `done` — наши хардлинки разложены (или восстановлены сверкой); `reverted` — Undo снял наши ссылки; `deleted` — ссылки сняты (Delete) либо констатировано их отсутствие (сверка), задача терминальна. + +Условие срабатывания система SHALL проверять **по состоянию-цели перехода** в +едином чекпоинте записи состояния. Такой гейт SHALL естественно покрывать как +пользовательские команды (Undo → `reverted`, Delete → `deleted`), так и +reconcile-производный `deleted` — инициатор перехода роли не играет; повторный/ +лишний скан безвреден (инкрементальный скан дёшев, операция идемпотентна). + +Состояния **вне** этого множества система сканировать SHALL NOT. Сюда входят как +входы, не меняющие наши ссылки (`review`, `linking`, `cancelled` через Dismiss), +так и **промежуточные состояния рассинхрона** `target_missing` и `orphaned`: там +раскладка ещё не улеглась — задача ждёт relink/восстановления и может +«залечиться» обратно в `done`, поэтому скан на них система откладывает, а не шлёт +на каждое колебание сверки. `target_missing` система не сканирует сознательно, +хотя цель там пропала: это внешняя пропажа при живом источнике, не наше снятие. + +Скан система SHALL выполнять **вне** блокировки воркера, в фоновом контексте и в +отдельной горутине, со scoped-логгером задачи для корреляции. Недоступность +Jellyfin на состояние задачи влиять SHALL NOT — ошибка вызова лишь логируется +(её пишет клиент Jellyfin как запись внешнего вызова). Если пересканирование не +сконфигурировано (`[jellyfin]` выключено), скан не дёргается ни в одном из этих +переходов. + +#### Scenario: Скан после раскладки + +- **GIVEN** пересканирование Jellyfin включено +- **WHEN** задача входит в `done` после успешной раскладки хардлинков +- **THEN** система неблокирующе дёргает `POST /Library/Refresh` + +#### Scenario: Скан после отката (Undo) + +- **GIVEN** пересканирование Jellyfin включено, задача в `done` с разложенными ссылками +- **WHEN** пользователь выполняет Undo и задача входит в `reverted` (наши ссылки сняты) +- **THEN** система неблокирующе дёргает `POST /Library/Refresh` + +#### Scenario: Скан после удаления (Delete) + +- **GIVEN** пересканирование Jellyfin включено, задача в `done` +- **WHEN** пользователь выполняет Delete и задача входит в `deleted` (наши ссылки сняты) +- **THEN** система неблокирующе дёргает `POST /Library/Refresh` + +#### Scenario: Без конфигурации Jellyfin скан не дёргается + +- **GIVEN** пересканирование Jellyfin выключено (`[jellyfin]` не сконфигурировано) +- **WHEN** задача входит в `done`, `reverted` или `deleted` +- **THEN** система скан не дёргает + +#### Scenario: Вход вне множества не сканирует + +- **GIVEN** пересканирование Jellyfin включено +- **WHEN** задача входит в состояние вне `{done, reverted, deleted}` (например, `review` или промежуточный `target_missing`) +- **THEN** система скан не дёргает +