Пересканирование 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:
@@ -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` надо дописать.
|
||||
@@ -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 подсетей).
|
||||
|
||||
@@ -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** — «Привязать заново»: после
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
|
||||
|
||||
+57
@@ -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
|
||||
<!-- нет новых capability -->
|
||||
|
||||
### 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 на состояние задачи не влияет.
|
||||
Источник неприкосновенен — скан лишь читает библиотеку.
|
||||
+56
@@ -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** система скан не дёргает
|
||||
+34
@@ -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` и строку из индекса беклога.
|
||||
@@ -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** система скан не дёргает
|
||||
|
||||
|
||||
Reference in New Issue
Block a user