Пересканирование 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:
@@ -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. НЕ сбрасываем запись при восстановлении — иначе дебаунс не гасил бы
|
||||
|
||||
Reference in New Issue
Block a user