Пересканирование 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
+64
View File
@@ -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