diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 4b47359..b3e0419 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -16,7 +16,6 @@ Tududi (проект `jellybit`) больше **не** держит беклог ## Высокий -- [Проблема второго сезона (сходимость папки сериала)](vtoroy-sezon-shodimost-papki.md) — Второй/третий сезон должен ложиться в ТУ ЖЕ папку сериала, а не заводить рядом почти… - [Раздачи с докачиванием (merge при повторном добавлении)](merge-dokachivanie.md) — Свежий сериал раздают по мере выхода: торрент с 5 из 10 эпизодов позже перезаливают… - [Ретеншн и очистка БД](retention-ochistka-bd.md) — Терминальные задачи (done/cancelled/failed/reverted), их попытки recognition с сырыми… - [Eval-харнес распознавания (корпус кейсов + метрика точности)](eval-harness-raspoznavaniya.md) — Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую… diff --git a/docs/backlog/vtoroy-sezon-shodimost-papki.md b/docs/backlog/vtoroy-sezon-shodimost-papki.md deleted file mode 100644 index 1383ddd..0000000 --- a/docs/backlog/vtoroy-sezon-shodimost-papki.md +++ /dev/null @@ -1,13 +0,0 @@ -# Проблема второго сезона (сходимость папки сериала) - -**Приоритет:** высокий - -Второй/третий сезон должен ложиться в ТУ ЖЕ папку сериала, а не заводить рядом почти одинаковую. Проблема не в группировке, а в сходимости папки: имя печатается заново из выхода LLM, совпадение provider_id не гарантирует совпадение строки («Fargo» vs «Фарго», год сезона vs год сериала). Отдельная сущность «тайтл» НЕ вводится. Решение — правило сходимости при построении плана: при подтверждённом матче наследовать базу папки (имя+год) от живых file_link загрузок с тем же (provider, provider_id), игнорируя LLM-выход; якоря нет → папка из распознавания, как сейчас. - -Шаги: -- lookup живых ссылок по (provider, provider_id) через current recognition -- наследование базы папки (имя+год) при построении плана раскладки -- рассинхрон (несколько живых папок с одним матчем) → review, не молча -- тесты: сходимость, отсутствие якоря (свежая папка), смена провайдера - -Связано: drafts/logical-title-model.md §5.2, specs/recognition.md, specs/jellyfin-layout.md diff --git a/docs/drafts/logical-title-model.md b/docs/drafts/logical-title-model.md index 31bac7d..75bb889 100644 --- a/docs/drafts/logical-title-model.md +++ b/docs/drafts/logical-title-model.md @@ -248,12 +248,19 @@ qBittorrent, но: v1/v2/гибрид дают разные значения; п ``` 1. ULID загрузки + download_infohash (дедуп переезжает). ← фундамент -2. правило сходимости папки при плане раскладки. ← «второй сезон» +2. правило сходимости папки при плане раскладки. ← «второй сезон» ✓ реализовано 3. merge-раскладка (докачивание: доложить недостающее). ← §6.2 4. группа «тайтл» в UI (вычисляемая) + удаление целиком (path 2). ← §6.4 (state_transition — вставить, когда захочется таймлайн/метрики) ``` +> Шаг 2 (правило сходимости папки) реализован — change +> `openspec/changes/archive/2026-07-10-series-folder-convergence/`, требования +> влиты в `openspec/specs/file-layout/`. Отличие от §5.2 черновика: живость якоря +> определяется существованием папки на диске (`os.Lstat`), а не только статусом +> ссылки; рассинхрон нескольких живых папок → review; in-app разрешение +> рассинхрона осознанно вне scope (ручной фикс на диске). + Каждый шаг — отдельный OpenSpec change; 1–2 самодостаточны и закрывают главную боль. diff --git a/docs/specs/jellyfin-layout.md b/docs/specs/jellyfin-layout.md index fc4629e..8bffb95 100644 --- a/docs/specs/jellyfin-layout.md +++ b/docs/specs/jellyfin-layout.md @@ -39,6 +39,11 @@ series/ - provider-id — на папке сериала. - Сезоны — `Season 01`, файлы — `... SxxEyy`. +- **Сходимость папки:** при подтверждённом матче база папки (имя+год) наследуется + от живой папки-якоря того же `(provider, provider_id)` (существующей на диске), а + не печатается заново из выхода LLM — так второй сезон ложится в ту же папку, что + и первый. Несколько разных живых папок одного матча → review. Источник истины — + `openspec/specs/file-layout/` («Сходимость базы папки…»). ## Сопоставление источник → цель diff --git a/internal/layout/layout.go b/internal/layout/layout.go index 0daf977..7a08ec4 100644 --- a/internal/layout/layout.go +++ b/internal/layout/layout.go @@ -20,6 +20,7 @@ import ( "log/slog" "os" "path/filepath" + "strings" "syscall" "git.vakhrushev.me/av/jellybit/internal/logctx" @@ -68,7 +69,13 @@ type Plan struct { Title string Year int ProviderTag string // напр. "tmdbid-693134"; пусто — без тега - Files []PlanFile + // FolderBase — унаследованная от живого якоря база имени ("Название (Год)"). + // Пусто → база печатается из Title/Year (первая загрузка тайтла). Непусто → + // перекрывает Title/Year и идёт и в папку, и в имена файлов (правило + // сходимости папки, см. file-layout spec). Provider-тег добавляется отдельно + // из ProviderTag. + FolderBase string + Files []PlanFile } // Link — посчитанная пара источник → цель. @@ -136,7 +143,7 @@ func (l *Layouter) BuildLinks(p Plan) ([]Link, error) { if err != nil { return nil, err } - base, err := titleYear(p.Title, p.Year) + base, err := planBase(p) if err != nil { return nil, err } @@ -209,6 +216,36 @@ func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Ki } } +// TitleFolder разбирает целевой путь dst живой ссылки в папку тайтла и её базу +// имени для правила сходимости (см. file-layout spec). Возвращает абсолютный +// путь папки тайтла (первый сегмент под корнем библиотеки типа t), её базу +// (снят хвостовой provider-тег) и ok. ok=false, если dst не под корнем нужной +// библиотеки или база пуста — вызывающий трактует как «нет якоря». Существование +// папки на диске здесь НЕ проверяется (это делает worker через os.Lstat). +func (l *Layouter) TitleFolder(t MediaType, dst string) (dir, base string, ok bool) { + root, err := l.root(t) + if err != nil { + return "", "", false + } + dst = filepath.Clean(dst) + if !underRoot(root, dst) { + return "", "", false + } + rel, err := filepath.Rel(root, dst) + if err != nil { + return "", "", false + } + first, _, _ := strings.Cut(rel, string(filepath.Separator)) + if first == "" || first == "." { + return "", "", false + } + base = folderBase(first) + if base == "" { + return "", "", false + } + return filepath.Join(root, first), base, true +} + // LinkStatus — исход создания одной ссылки. type LinkStatus string diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go index 08e6876..7162c62 100644 --- a/internal/layout/layout_test.go +++ b/internal/layout/layout_test.go @@ -98,6 +98,78 @@ func TestBuildLinks_Series(t *testing.T) { } } +func TestBuildLinks_FolderBaseOverridesSeries(t *testing.T) { + f := newFixture(t) + // LLM дал «Fargo» 2017, но живой якорь — «Фарго (2014)». FolderBase должна + // перекрыть Title/Year и в папке, И в имени файла. + plan := Plan{ + Type: Series, Title: "Fargo", Year: 2017, ProviderTag: "tvdbid-269613", + FolderBase: "Фарго (2014)", + Files: []PlanFile{ + {Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(2), Episode: intp(1)}, + }, + } + links, err := f.l.BuildLinks(plan) + if err != nil { + t.Fatalf("BuildLinks: %v", err) + } + want := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 02", "Фарго (2014) S02E01.mkv") + if links[0].Dst != want { + t.Errorf("ep = %q, want %q", links[0].Dst, want) + } +} + +func TestBuildLinks_FolderBaseOverridesMovie(t *testing.T) { + f := newFixture(t) + plan := Plan{ + Type: Movie, Title: "Dune", Year: 2021, ProviderTag: "tmdbid-693134", + FolderBase: "Дюна Часть вторая (2024)", + Files: []PlanFile{{Src: f.srcFile(t, "m/f.mkv", "1"), Role: RoleMain}}, + } + links, err := f.l.BuildLinks(plan) + if err != nil { + t.Fatalf("BuildLinks: %v", err) + } + want := filepath.Join(f.movies, "Дюна Часть вторая (2024) [tmdbid-693134]", "Дюна Часть вторая (2024).mkv") + if links[0].Dst != want { + t.Errorf("main = %q, want %q", links[0].Dst, want) + } +} + +func TestTitleFolder(t *testing.T) { + f := newFixture(t) + epDst := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 01", "Фарго (2014) S01E01.mkv") + dir, base, ok := f.l.TitleFolder(Series, epDst) + if !ok { + t.Fatal("want ok for path under series root") + } + if wantDir := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]"); dir != wantDir { + t.Errorf("dir = %q, want %q", dir, wantDir) + } + if base != "Фарго (2014)" { + t.Errorf("base = %q, want %q", base, "Фарго (2014)") + } + + // Фильм: папка тайтла — первый сегмент, база без тега. + mvDst := filepath.Join(f.movies, "Dune (2024) [tmdbid-1]", "Dune (2024).mkv") + if dir, base, ok := f.l.TitleFolder(Movie, mvDst); !ok || + dir != filepath.Join(f.movies, "Dune (2024) [tmdbid-1]") || base != "Dune (2024)" { + t.Errorf("movie: dir=%q base=%q ok=%v", dir, base, ok) + } + + // Путь не под корнем нужной библиотеки → not ok. + if _, _, ok := f.l.TitleFolder(Series, mvDst); ok { + t.Error("movie path must not resolve under series root") + } + if _, _, ok := f.l.TitleFolder(Movie, "/etc/passwd"); ok { + t.Error("path outside library must be rejected") + } + // Сам корень (нет сегмента папки) → not ok. + if _, _, ok := f.l.TitleFolder(Movie, f.movies); ok { + t.Error("root itself has no title folder") + } +} + func TestBuildLinks_SeriesEpisodeWithoutNumber(t *testing.T) { f := newFixture(t) plan := Plan{ diff --git a/internal/layout/name.go b/internal/layout/name.go index 6a1592f..1ef1332 100644 --- a/internal/layout/name.go +++ b/internal/layout/name.go @@ -40,6 +40,20 @@ func titleYear(title string, year int) (string, error) { return t, nil } +// planBase выбирает базу имени плана: унаследованная от живого якоря +// FolderBase (правило сходимости папки) перекрывает Title/Year. База в любом +// случае санитизируется (FolderBase пришла с диска — прогон идемпотентен). +func planBase(p Plan) (string, error) { + if p.FolderBase != "" { + b := sanitizeComponent(p.FolderBase) + if b == "" { + return "", fmt.Errorf("layout: empty folder base after sanitization (%q)", p.FolderBase) + } + return b, nil + } + return titleYear(p.Title, p.Year) +} + // folderName добавляет provider-тег к базе: "Название (Год) [tmdbid-123]". func folderName(base, providerTag string) string { tag := sanitizeComponent(providerTag) @@ -49,6 +63,19 @@ func folderName(base, providerTag string) string { return fmt.Sprintf("%s [%s]", base, tag) } +// folderBase восстанавливает базу имени ("Название (Год)") из имени папки +// тайтла, снимая хвостовой provider-тег " [...]" (любой, а не только текущий: +// у переоценённого якоря тег мог остаться старым). Тег без пробела перед "[" +// или незакрытый — не трогаем. Результат санитизируется; пустой → "" (вызывающий +// трактует как «нет якоря»). +func folderBase(folder string) string { + base := folder + if i := strings.LastIndex(folder, " ["); i >= 0 && strings.HasSuffix(folder, "]") { + base = folder[:i] + } + return sanitizeComponent(base) +} + // seasonFolder — "Season 00" (спецвыпуски) / "Season 01" / ... func seasonFolder(season int) string { return fmt.Sprintf("Season %02d", season) diff --git a/internal/layout/name_test.go b/internal/layout/name_test.go index f896676..20174cf 100644 --- a/internal/layout/name_test.go +++ b/internal/layout/name_test.go @@ -47,6 +47,25 @@ func TestFolderName(t *testing.T) { } } +func TestFolderBase(t *testing.T) { + tests := []struct { + in, want string + }{ + {"Фарго (2014) [tvdbid-269613]", "Фарго (2014)"}, // текущий тег + {"Fargo (2017) [tmdbid-123]", "Fargo (2017)"}, // чужой/старый тег снимается так же + {"No Tag (2020)", "No Tag (2020)"}, // без тега — как есть + {"Bare Name", "Bare Name"}, // без года и тега + {"Weird [not a tag", "Weird [not a tag"}, // незакрытый — не трогаем + {"[tvdbid-1]", "[tvdbid-1]"}, // нет " [" с пробелом — не тег + {"Movie (2020) [edition-Director's Cut]", "Movie (2020)"}, // снимается любой хвостовой [...] + } + for _, tt := range tests { + if got := folderBase(tt.in); got != tt.want { + t.Errorf("folderBase(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + func TestEpisodeStem(t *testing.T) { if got := episodeStem("Fargo (2015)", 2, 1, 0); got != "Fargo (2015) S02E01" { t.Errorf("got %q", got) diff --git a/internal/store/recognition.go b/internal/store/recognition.go index 7a5b3d1..def089a 100644 --- a/internal/store/recognition.go +++ b/internal/store/recognition.go @@ -293,6 +293,32 @@ func (s *Store) LayoutSizeByDownload(ctx context.Context, ids []string) (map[str return out, nil } +// LiveTitleFolders возвращает целевые пути (dst_path) живых ссылок +// (`status IN linked/copied/exists`) загрузок, чьё current recognition имеет тот +// же (provider, provider_id), КРОМЕ excludeDownloadID. Питает правило сходимости +// папки (см. file-layout): вызывающий сводит пути к папкам тайтла, проверяет их +// существование на диске и наследует базу имени. Пустой provider/id → пусто (без +// подтверждённого матча якоря нет). Проверка живости папки на диске — +// ответственность вызывающего (статус ссылки не гарантирует наличие папки). +func (s *Store) LiveTitleFolders(ctx context.Context, provider, providerID, excludeDownloadID string) ([]string, error) { + if provider == "" || providerID == "" { + return nil, nil + } + const q = ` +SELECT fl.dst_path +FROM file_link fl +JOIN recognition r ON r.download_id = fl.download_id AND r.is_current = 1 +WHERE fl.status IN ('linked', 'copied', 'exists') + AND fl.download_id != ? + AND r.provider = ? + AND r.provider_id = ?` + var out []string + if err := s.DB.SelectContext(ctx, &out, q, excludeDownloadID, provider, providerID); err != nil { + return nil, fmt.Errorf("live title folders: %w", err) + } + return out, nil +} + // DeleteFileLinksByBatch удаляет записи ссылок батча (после undo на ФС). func (s *Store) DeleteFileLinksByBatch(ctx context.Context, batchID string) error { if _, err := s.DB.ExecContext(ctx, diff --git a/internal/store/recognition_test.go b/internal/store/recognition_test.go index 42ff0f8..4bbfeda 100644 --- a/internal/store/recognition_test.go +++ b/internal/store/recognition_test.go @@ -289,6 +289,66 @@ func TestCandidates_Lifecycle(t *testing.T) { } } +func TestLiveTitleFolders(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + + // anchor и other сматчены на один (tvdb, 269613); alien — на другой матч. + anchor := mustCreate(t, st, "1100ccddeeff00112233445566778899aabbccdd") + other := mustCreate(t, st, "2200ccddeeff00112233445566778899aabbccdd") + alien := mustCreate(t, st, "3300ccddeeff00112233445566778899aabbccdd") + + seedMatch := func(dl, provider, id string) { + if _, err := st.CreateRecognition(ctx, &Recognition{ + DownloadID: dl, Provider: NullString(provider), ProviderID: NullString(id), + }, nil); err != nil { + t.Fatalf("seed recognition: %v", err) + } + } + seedMatch(anchor, "tvdb", "269613") + seedMatch(other, "tvdb", "269613") + seedMatch(alien, "tvdb", "999") + + if err := st.CreateFileLinks(ctx, []FileLink{ + {DownloadID: anchor, ApplyBatchID: "a", SrcPath: "/d/a.mkv", DstPath: "/s/Фарго (2014) [tvdbid-269613]/Season 01/e.mkv", Kind: "video", Status: "linked"}, + {DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/o.mkv", DstPath: "/s/Фарго (2014) [tvdbid-269613]/Season 02/e.mkv", Kind: "video", Status: "exists"}, + // superseded/collision у other — не живые, не должны попасть. + {DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/x.mkv", DstPath: "/s/Old/e.mkv", Kind: "video", Status: "superseded"}, + {DownloadID: other, ApplyBatchID: "o", SrcPath: "/d/y.mkv", DstPath: "/s/Coll/e.mkv", Kind: "video", Status: "collision"}, + // alien — другой матч, мимо. + {DownloadID: alien, ApplyBatchID: "z", SrcPath: "/d/z.mkv", DstPath: "/s/Other/e.mkv", Kind: "video", Status: "linked"}, + }); err != nil { + t.Fatalf("create links: %v", err) + } + + // Из позиции anchor: видим только живую ссылку other (себя исключаем). + got, err := st.LiveTitleFolders(ctx, "tvdb", "269613", anchor) + if err != nil { + t.Fatalf("live title folders: %v", err) + } + if len(got) != 1 || got[0] != "/s/Фарго (2014) [tvdbid-269613]/Season 02/e.mkv" { + t.Errorf("got %v, want только живую ссылку other", got) + } + + // Без исключения — обе живые ссылки матча (anchor + other), но не superseded/ + // collision/alien. + got, err = st.LiveTitleFolders(ctx, "tvdb", "269613", "") + if err != nil { + t.Fatalf("live title folders: %v", err) + } + if len(got) != 2 { + t.Errorf("got %v, want 2 живые ссылки", got) + } + + // Пустой provider/id → пусто (нет подтверждённого матча). + if got, _ := st.LiveTitleFolders(ctx, "", "269613", ""); got != nil { + t.Errorf("пустой provider → пусто, got %v", got) + } + if got, _ := st.LiveTitleFolders(ctx, "tvdb", "", ""); got != nil { + t.Errorf("пустой id → пусто, got %v", got) + } +} + func TestGetCandidate_None(t *testing.T) { st := newTestStore(t) c, err := st.GetCandidate(context.Background(), "01hzzzzzzzzzzzzzzzzzzzzzzz") diff --git a/internal/worker/review.go b/internal/worker/review.go index e701c83..c2bac72 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "maps" + "os" "path/filepath" "strconv" "strings" @@ -132,10 +133,9 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize planJSON = []byte("{}") } - provider, providerID, tag := "none", "", "" + provider, providerID := "none", "" if res.Match != nil { provider, providerID = res.Match.Provider, res.Match.ProviderID - tag = providerTag(res.Match.Provider, res.Match.ProviderID) } rec := &store.Recognition{ @@ -197,11 +197,13 @@ func (w *Worker) finishRecognition(ctx context.Context, id string, res recognize // Claim перехода в linking должен закоммититься до хардлинков (MINOR-7): // при провале записи не линкуем — задача остаётся в recognizing, и // поллинг-цикл (recognizePending) повторит распознавание/авто-раскладку. + // Правило сходимости папки (и уход в review при рассинхроне) — внутри + // linkPlan, из состояния linking. if err := w.transitionErr(lctx, *d, store.StateLinking, "", ""); err != nil { logctx.From(lctx).Warn("auto-apply claim failed, left for recognizing", "error", err) return } - if err := w.linkPlan(lctx, d, plan, tag, savePath); err != nil { + if err := w.linkPlan(lctx, d, plan, provider, providerID, savePath); err != nil { logctx.From(lctx).Warn("auto-apply failed, left for review", "error", err) } return @@ -239,7 +241,7 @@ func (w *Worker) Apply(ctx context.Context, id string) error { } ctx = w.scoped(ctx, capFileLayout, id, d.PrimaryInfohash()) - plan, tag, err := w.effectivePlan(ctx, id) + plan, prov, pid, err := w.effectivePlan(ctx, id) if err != nil { return fmt.Errorf("apply: %w", err) } @@ -262,21 +264,36 @@ func (w *Worker) Apply(ctx context.Context, id string) error { // Claim перехода в linking ОБЯЗАН закоммититься до создания хардлинков: при // провале записи не линкуем (иначе ссылки лягут при задаче в review, а // финальный linking→done граф отклонит — MINOR-7). Задача остаётся в - // review/deferred, повтор безопасен. + // review/deferred, повтор безопасен. Правило сходимости папки (и уход в + // review при рассинхроне) — уже внутри linkPlan, из состояния linking. if err := w.transitionErr(ctx, *d, store.StateLinking, "", ""); err != nil { return fmt.Errorf("apply: %w", err) } - if err := w.linkPlan(ctx, d, plan, tag, translatePath(t.SavePath, w.cfg.PathMap)); err != nil { + if err := w.linkPlan(ctx, d, plan, prov, pid, translatePath(t.SavePath, w.cfg.PathMap)); err != nil { return fmt.Errorf("apply: %w", err) } return nil } // linkPlan строит и создаёт хардлинки по плану, фиксирует батч ссылок и -// двигает задачу: done при успехе, review при коллизии/невалидном плане, -// failed при иной ошибке ФС. Идемпотентен (повтор доводит начатое). Под mu. -func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize.Plan, providerTag, savePath string) error { - links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag)) +// двигает задачу: done при успехе, review при коллизии/невалидном плане/ +// рассинхроне папок, failed при иной ошибке ФС. Идемпотентен (повтор доводит +// начатое). Под mu; вызывается уже в состоянии linking (claim закоммичен) — +// поэтому уход в review здесь легален (linking→review), как для коллизии. +func (w *Worker) linkPlan(ctx context.Context, d *store.Download, plan recognize.Plan, provider, providerID, savePath string) error { + // Правило сходимости папки: при подтверждённом матче база наследуется от + // живого якоря; рассинхрон (несколько разных живых папок) → review. + folderBase, desync, err := w.resolveFolderBase(ctx, d.ID, provider, providerID, layout.MediaType(plan.Type)) + if err != nil { + w.transition(ctx, *d, store.StateReview, "resolve", err.Error()) + return fmt.Errorf("link plan: %w", err) + } + if desync { + w.transition(ctx, *d, store.StateReview, reasonTitleFolderDesync, "несколько живых папок тайтла с одним матчем") + return fmt.Errorf("рассинхрон папок тайтла: %w", ErrConflict) + } + + links, err := w.layouter.BuildLinks(toLayoutPlan(plan, savePath, providerTag(provider, providerID), folderBase)) if err != nil { w.transition(ctx, *d, store.StateReview, "build", err.Error()) return fmt.Errorf("build links: %w", err) @@ -916,9 +933,15 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) rd.Plan = applyOverrides(rawPlan, overrides) // Превью активного источника строим по относительным путям с // provider-тегом; ошибку логируем на Debug — покажем без превью. + // База наследуется тем же правилом сходимости, что и применение + // (превью=применение); рассинхрон в превью не переводит в review. if w.layouter != nil { tag := providerTag(prov, pid) - if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag)); lerr == nil { + base, _, berr := w.resolveFolderBase(ctx, id, prov, pid, layout.MediaType(rd.Plan.Type)) + if berr != nil { + log.Debug("review data resolve folder base failed", "error", berr) + } + if links, lerr := w.layouter.BuildLinks(toLayoutPlan(rd.Plan, "", tag, base)); lerr == nil { rd.Preview = links } else { log.Debug("review data build preview failed", "error", lerr) @@ -926,7 +949,7 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) } // Единый список источников: нейронка + кандидаты, каждый с // эфемерным превью из сырого плана (без записи overrides). - rd.Sources = w.buildSources(rawPlan, overrides, prov, pid, rd.Candidates) + rd.Sources = w.buildSources(ctx, id, rawPlan, overrides, prov, pid, rd.Candidates) } } return rd, nil @@ -935,10 +958,10 @@ func (w *Worker) ReviewData(ctx context.Context, id string) (*ReviewData, error) // buildSources собирает единый список источников: нейронка (первой) + // кандидаты (дедуп по provider:id). Активным помечается текущий эффективный // источник. -func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]string, prov, pid string, cands []store.MetadataCandidate) []SourceOption { +func (w *Worker) buildSources(ctx context.Context, downloadID string, rawPlan recognize.Plan, overrides map[string]string, prov, pid string, cands []store.MetadataCandidate) []SourceOption { neutral := prov == "" || prov == "none" out := make([]SourceOption, 0, len(cands)+1) - out = append(out, w.sourceOption(SourceNeural, rawPlan, overrides, "", "none", "", "", "", 0, neutral)) + out = append(out, w.sourceOption(ctx, downloadID, SourceNeural, rawPlan, overrides, "", "none", "", "", "", 0, neutral)) seen := map[string]bool{} for _, c := range cands { key := c.Provider + ":" + c.ProviderID @@ -955,7 +978,7 @@ func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]strin year = int(c.Year.Int64) } active := !neutral && c.Provider == prov && c.ProviderID == pid - out = append(out, w.sourceOption(SourceCandidate, rawPlan, overrides, c.ID, c.Provider, c.ProviderID, c.URL.String, title, year, active)) + out = append(out, w.sourceOption(ctx, downloadID, SourceCandidate, rawPlan, overrides, c.ID, c.Provider, c.ProviderID, c.URL.String, title, year, active)) } return out } @@ -963,7 +986,7 @@ func (w *Worker) buildSources(rawPlan recognize.Plan, overrides map[string]strin // sourceOption строит один источник: накладывает его пины на неисточниковые // overrides, считает эффективный план и предпросмотр путей — эфемерно, без // записи. Гарантия preview == apply: тот же набор пинов запишет выбор. -func (w *Worker) sourceOption(kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption { +func (w *Worker) sourceOption(ctx context.Context, downloadID string, kind SourceKind, rawPlan recognize.Plan, base map[string]string, candID, provider, providerID, url, title string, year int, active bool) SourceOption { eff := applyOverrides(rawPlan, mergeSourceOverrides(base, sourcePins(provider, providerID, title, year))) opt := SourceOption{ Kind: kind, @@ -978,7 +1001,10 @@ func (w *Worker) sourceOption(kind SourceKind, rawPlan recognize.Plan, base map[ Plan: eff, } if w.layouter != nil { - if links, err := w.layouter.BuildLinks(toLayoutPlan(eff, "", providerTag(provider, providerID))); err == nil { + // Превью источника наследует базу тем же правилом сходимости, что и + // применение (превью=применение); рассинхрон в превью не переводит в review. + folderBase, _, _ := w.resolveFolderBase(ctx, downloadID, provider, providerID, layout.MediaType(eff.Type)) + if links, err := w.layouter.BuildLinks(toLayoutPlan(eff, "", providerTag(provider, providerID), folderBase)); err == nil { opt.Preview = links } } @@ -1002,25 +1028,68 @@ func mergeSourceOverrides(base, pins map[string]string) map[string]string { } // effectivePlan загружает текущий план, применяет правки и возвращает -// provider-тег для имени папки (под mu). -func (w *Worker) effectivePlan(ctx context.Context, id string) (recognize.Plan, string, error) { +// эффективные provider/provider_id (для тега папки и правила сходимости) (под mu). +func (w *Worker) effectivePlan(ctx context.Context, id string) (plan recognize.Plan, provider, providerID string, err error) { rec, err := w.store.GetCurrentRecognition(ctx, id) if err != nil { - return recognize.Plan{}, "", err + return recognize.Plan{}, "", "", err } if rec == nil || !rec.Plan.Valid { - return recognize.Plan{}, "", fmt.Errorf("no recognition plan") + return recognize.Plan{}, "", "", fmt.Errorf("no recognition plan") } - var plan recognize.Plan if err := json.Unmarshal([]byte(rec.Plan.String), &plan); err != nil { - return recognize.Plan{}, "", fmt.Errorf("parse plan: %w", err) + return recognize.Plan{}, "", "", fmt.Errorf("parse plan: %w", err) } overrides, err := w.store.ListOverrides(ctx, id) if err != nil { - return recognize.Plan{}, "", err + return recognize.Plan{}, "", "", err } prov, pid := effectiveProvider(rec, overrides) - return applyOverrides(plan, overrides), providerTag(prov, pid), nil + return applyOverrides(plan, overrides), prov, pid, nil +} + +// reasonTitleFolderDesync — код причины ухода в review, когда у тайтла нашлось +// несколько разных живых папок с одним матчем (правило сходимости папки). +const reasonTitleFolderDesync = "title_folder_desync" + +// resolveFolderBase применяет правило сходимости папки (см. file-layout spec): +// при подтверждённом матче наследует базу имени от живой папки-якоря того же +// (provider, providerID), кроме самой загрузки downloadID. Возвращает базу для +// layout.Plan.FolderBase (пусто → печатать из распознавания), флаг рассинхрона +// (≥2 разных живых папок) и ошибку. Чистая (только чтение БД+ФС), без побочных +// эффектов — переиспользуется применением и превью. Живость якоря определяется +// существованием папки на диске (os.Lstat), а не статусом ссылки в БД. +func (w *Worker) resolveFolderBase(ctx context.Context, downloadID, provider, providerID string, mt layout.MediaType) (base string, desync bool, err error) { + if provider == "" || provider == "none" || providerID == "" || w.layouter == nil { + return "", false, nil + } + paths, err := w.store.LiveTitleFolders(ctx, provider, providerID, downloadID) + if err != nil { + return "", false, fmt.Errorf("resolve folder base: %w", err) + } + bases := make(map[string]string, 2) // абсолютная папка тайтла → база имени + for _, p := range paths { + dir, b, ok := w.layouter.TitleFolder(mt, p) + if !ok { + continue // не под корнем / не разобрать — не якорь + } + if _, seen := bases[dir]; seen { + continue + } + if _, serr := os.Lstat(dir); serr != nil { + continue // папки на диске нет (переименована/удалена) — не якорь + } + bases[dir] = b + } + switch len(bases) { + case 0: + return "", false, nil + case 1: + for _, b := range bases { + return b, false, nil + } + } + return "", true, nil // рассинхрон: несколько разных живых папок } // --- Хелперы преобразования --- @@ -1093,7 +1162,7 @@ func ProviderTag(provider, id string) string { return providerTag(provider, id) // ToLayoutPlan — экспорт toLayoutPlan для диагностических команд. func ToLayoutPlan(p recognize.Plan, srcPrefix, providerTag string) layout.Plan { - return toLayoutPlan(p, srcPrefix, providerTag) + return toLayoutPlan(p, srcPrefix, providerTag, "") } // providerTag строит тег папки для Jellyfin из провайдера и id: "tmdbid-…" @@ -1116,14 +1185,17 @@ func providerTag(provider, id string) string { // toLayoutPlan переводит план распознавания в план раскладки. srcPrefix // (savePath) приклеивается к относительным путям файлов; пустой — оставляет -// относительные (для превью). providerTag добавляется к имени папки. Роли -// вне main/episode/subtitle отбрасываются. -func toLayoutPlan(plan recognize.Plan, srcPrefix, providerTag string) layout.Plan { +// относительные (для превью). providerTag добавляется к имени папки. folderBase +// (непустой) — унаследованная от живого якоря база имени (правило сходимости): +// перекрывает Title/Year в папке и в именах файлов. Роли вне +// main/episode/subtitle отбрасываются. +func toLayoutPlan(plan recognize.Plan, srcPrefix, providerTag, folderBase string) layout.Plan { lp := layout.Plan{ Type: layout.MediaType(plan.Type), Title: plan.Title, Year: plan.Year, ProviderTag: providerTag, + FolderBase: folderBase, } for _, f := range plan.Files { role, ok := mapRole(f.Role) diff --git a/internal/worker/review_test.go b/internal/worker/review_test.go index 0bfc18c..4b8529c 100644 --- a/internal/worker/review_test.go +++ b/internal/worker/review_test.go @@ -10,6 +10,7 @@ import ( "log/slog" "os" "path/filepath" + "strings" "testing" "time" @@ -557,6 +558,27 @@ func (m *memStore) SupersedeForeignLinks(_ context.Context, downloadID string, d } return nil } +func (m *memStore) LiveTitleFolders(_ context.Context, provider, providerID, excludeDownloadID string) ([]string, error) { + if provider == "" || providerID == "" { + return nil, nil + } + match := map[string]bool{} // download_id с current recognition этого матча + for _, r := range m.recs { + if r.IsCurrent && r.Provider.String == provider && r.ProviderID.String == providerID { + match[r.DownloadID] = true + } + } + var out []string + for _, l := range m.links { + if l.DownloadID == excludeDownloadID || !match[l.DownloadID] { + continue + } + if isLaidOut(l.Status) { + out = append(out, l.DstPath) + } + } + return out, nil +} func (m *memStore) LatestBatchID(_ context.Context, id string) (string, error) { for i := len(m.links) - 1; i >= 0; i-- { if m.links[i].DownloadID == id { @@ -1016,6 +1038,219 @@ func TestApply_SupersedesForeignOwnerOfPath(t *testing.T) { } } +// --- Правило сходимости папки (проблема второго сезона) --- + +// matchCurrentRec помечает current recognition загрузки id подтверждённым +// матчем (для правила сходимости). +func matchCurrentRec(f applyFixture, id, provider, providerID string) { + for _, r := range f.st.recs { + if r.DownloadID == id && r.IsCurrent { + r.Provider = store.NullString(provider) + r.ProviderID = store.NullString(providerID) + } + } +} + +// seedLiveAnchor заводит живой якорь тайтла: current recognition матча + +// разложенную ссылку на папку folder под series, и создаёт эту папку на диске. +func seedLiveAnchor(t *testing.T, f applyFixture, dlID, provider, providerID, folder string, onDisk bool) { + t.Helper() + dst := filepath.Join(f.series, folder, "Season 01", folderBaseTest(folder)+" S01E01.mkv") + if onDisk { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("anchor"), 0o644); err != nil { + t.Fatal(err) + } + } + f.st.recs = append(f.st.recs, &store.Recognition{ + ID: "rec-" + dlID, DownloadID: dlID, IsCurrent: true, + Provider: store.NullString(provider), ProviderID: store.NullString(providerID), + }) + f.st.links = append(f.st.links, store.FileLink{ + DownloadID: dlID, ApplyBatchID: "anchor-" + dlID, SrcPath: "/anchor/e1.mkv", + DstPath: dst, Kind: "video", Status: "linked", + }) +} + +// folderBaseTest — «Фарго (2014)» из «Фарго (2014) [tvdbid-269613]» (для имён +// файлов якоря в тесте). +func folderBaseTest(folder string) string { + if i := strings.LastIndex(folder, " ["); i >= 0 { + return folder[:i] + } + return folder +} + +func TestApply_FolderConvergence_SecondSeason(t *testing.T) { + // Текущая загрузка распознана как «Show (2006)», но матч tvdb=269613 + // совпадает с живым якорем «Фарго (2014)» → наследуем его базу. + f := newApplyFixture(t, seriesResult().Plan) + matchCurrentRec(f, "1", "tvdb", "269613") + seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true) + + if err := f.w.Apply(context.Background(), "1"); err != nil { + t.Fatalf("Apply: %v", err) + } + // Второй сезон лёг в папку якоря, база — унаследованная и в имени файла. + want := filepath.Join(f.series, "Фарго (2014) [tvdbid-269613]", "Season 02", "Фарго (2014) S02E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Errorf("ожидали раскладку в папку якоря %q: %v", want, err) + } + // Своей папки «Show (2006)» быть не должно. + if _, err := os.Stat(filepath.Join(f.series, "Show (2006) [tvdbid-269613]")); err == nil { + t.Error("создана отдельная папка вместо сходимости") + } +} + +func TestApply_FolderConvergence_NoAnchorPrintsFresh(t *testing.T) { + // Матч есть, но живого якоря нет → печатаем базу из распознавания. + f := newApplyFixture(t, seriesResult().Plan) + matchCurrentRec(f, "1", "tvdb", "269613") + + if err := f.w.Apply(context.Background(), "1"); err != nil { + t.Fatalf("Apply: %v", err) + } + want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Errorf("ожидали свежую папку из распознавания %q: %v", want, err) + } +} + +func TestApply_FolderConvergence_ProviderMismatchNoInherit(t *testing.T) { + // Якорь заматчен другим провайдером (tmdb) → по (tvdb,269613) не находится, + // базу не наследуем. + f := newApplyFixture(t, seriesResult().Plan) + matchCurrentRec(f, "1", "tvdb", "269613") + seedLiveAnchor(t, f, "2", "tmdb", "60622", "Фарго (2014) [tmdbid-60622]", true) + + if err := f.w.Apply(context.Background(), "1"); err != nil { + t.Fatalf("Apply: %v", err) + } + want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Errorf("смена провайдера не должна склеивать; ожидали %q: %v", want, err) + } +} + +func TestApply_FolderConvergence_RenamedAnchorPrintsFresh(t *testing.T) { + // Ссылка якоря живая в БД, но папки на диске нет (переименована) → не якорь. + f := newApplyFixture(t, seriesResult().Plan) + matchCurrentRec(f, "1", "tvdb", "269613") + seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", false) // onDisk=false + + if err := f.w.Apply(context.Background(), "1"); err != nil { + t.Fatalf("Apply: %v", err) + } + want := filepath.Join(f.series, "Show (2006) [tvdbid-269613]", "Season 02", "Show (2006) S02E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Errorf("отсутствующая на диске папка не якорь; ожидали %q: %v", want, err) + } +} + +func TestApply_FolderConvergence_DesyncGoesReview(t *testing.T) { + // Две разные живые папки одного матча → рассинхрон → review, без раскладки. + f := newApplyFixture(t, seriesResult().Plan) + matchCurrentRec(f, "1", "tvdb", "269613") + seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true) + seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true) + + err := f.w.Apply(context.Background(), "1") + if err == nil { + t.Fatal("ожидали ошибку рассинхрона") + } + if f.st.downloads["1"].State != store.StateReview { + t.Errorf("state = %q, want review", f.st.downloads["1"].State) + } + if f.st.downloads["1"].ErrorCode.String != reasonTitleFolderDesync { + t.Errorf("error_code = %q, want %q", f.st.downloads["1"].ErrorCode.String, reasonTitleFolderDesync) + } + // Раскладки не было. + if len(f.st.links) != 2 { // только два якоря + t.Errorf("file_links = %d, раскладка не должна была выполниться", len(f.st.links)) + } +} + +func TestApply_FolderConvergence_DesyncFromDeferredGoesReview(t *testing.T) { + // Регрессия: apply из deferred при рассинхроне. Уход в review идёт из linking + // (linking→review легален), а не прямым deferred→review (ребра в графе нет). + f := newApplyFixture(t, seriesResult().Plan) + f.st.downloads["1"].State = store.StateDeferred + matchCurrentRec(f, "1", "tvdb", "269613") + seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true) + seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true) + + if err := f.w.Apply(context.Background(), "1"); err == nil { + t.Fatal("ожидали ошибку рассинхрона") + } + if f.st.downloads["1"].State != store.StateReview { + t.Errorf("state = %q, want review", f.st.downloads["1"].State) + } + if f.st.downloads["1"].ErrorCode.String != reasonTitleFolderDesync { + t.Errorf("error_code = %q, want %q", f.st.downloads["1"].ErrorCode.String, reasonTitleFolderDesync) + } +} + +func TestReviewData_PreviewMatchesConvergedApply(t *testing.T) { + // Превью активного источника наследует ту же базу, что даст применение. + f := newApplyFixture(t, seriesResult().Plan) + matchCurrentRec(f, "1", "tvdb", "269613") + seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true) + + rd, err := f.w.ReviewData(context.Background(), "1") + if err != nil { + t.Fatalf("ReviewData: %v", err) + } + if len(rd.Preview) == 0 { + t.Fatal("превью пусто") + } + // Превью ведёт в папку якоря (относительные пути, с тегом). + if !strings.Contains(rd.Preview[0].Dst, filepath.Join("Фарго (2014) [tvdbid-269613]", "Season 02")) { + t.Errorf("превью = %q, ожидали папку якоря", rd.Preview[0].Dst) + } +} + +func TestApply_FolderConvergence_NoMatchNoInherit(t *testing.T) { + // Матча нет (provider=none), но рядом есть живой якорь — наследования быть не + // должно (сходимость только при подтверждённом матче). + f := newApplyFixture(t, seriesResult().Plan) // current rec без provider + seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true) + + if err := f.w.Apply(context.Background(), "1"); err != nil { + t.Fatalf("Apply: %v", err) + } + // Без матча тег пуст, база из распознавания. + want := filepath.Join(f.series, "Show (2006)", "Season 02", "Show (2006) S02E01.mkv") + if _, err := os.Stat(want); err != nil { + t.Errorf("без матча наследования нет; ожидали %q: %v", want, err) + } +} + +func TestReviewData_PreviewDesyncPrintsFreshNoReview(t *testing.T) { + // Рассинхрон в превью НЕ переводит задачу в review (превью информационно): + // база печатается из распознавания, состояние остаётся review. + f := newApplyFixture(t, seriesResult().Plan) + matchCurrentRec(f, "1", "tvdb", "269613") + seedLiveAnchor(t, f, "2", "tvdb", "269613", "Фарго (2014) [tvdbid-269613]", true) + seedLiveAnchor(t, f, "3", "tvdb", "269613", "Fargo (2017) [tvdbid-269613]", true) + + rd, err := f.w.ReviewData(context.Background(), "1") + if err != nil { + t.Fatalf("ReviewData: %v", err) + } + if f.st.downloads["1"].State != store.StateReview { + t.Errorf("state = %q, показ превью не должен менять состояние", f.st.downloads["1"].State) + } + if len(rd.Preview) == 0 { + t.Fatal("превью пусто") + } + // База из распознавания (не унаследованная), тег текущего матча. + if !strings.Contains(rd.Preview[0].Dst, filepath.Join("Show (2006) [tvdbid-269613]", "Season 02")) { + t.Errorf("превью при рассинхроне = %q, ожидали свежую базу из распознавания", rd.Preview[0].Dst) + } +} + func TestApply_CollisionKeepsForeignOwner(t *testing.T) { // Если файл прежней загрузки ВСЁ ЕЩЁ на месте — коллизия → review, владение // не отбирается (supersede не срабатывает для не-разложенного пути). @@ -1374,14 +1609,14 @@ func TestChooseCandidate_PinsOverrides(t *testing.T) { t.Error("кандидат не помечен выбранным") } // Эффективный план берёт каноническое имя/год и тег [tvdbid-...]. - plan, tag, err := w.effectivePlan(context.Background(), "1") + plan, prov, pid, err := w.effectivePlan(context.Background(), "1") if err != nil { t.Fatalf("effectivePlan: %v", err) } if plan.Title != "Fargo" || plan.Year != 2014 { t.Errorf("plan = %q (%d)", plan.Title, plan.Year) } - if tag != "tvdbid-269613" { + if tag := providerTag(prov, pid); tag != "tvdbid-269613" { t.Errorf("tag = %q", tag) } } @@ -1419,8 +1654,8 @@ func TestClearProvider(t *testing.T) { t.Errorf("provider override = %q, want none", st.overrides["1"][ovrProvider]) } // «Без базы» → пустой тег. - _, tag, _ := w.effectivePlan(context.Background(), "1") - if tag != "" { + _, prov, pid, _ := w.effectivePlan(context.Background(), "1") + if tag := providerTag(prov, pid); tag != "" { t.Errorf("tag = %q, want empty", tag) } } @@ -1441,14 +1676,14 @@ func TestClearProvider_ClearsInheritedTitle(t *testing.T) { t.Fatalf("ClearProvider: %v", err) } // Название/год снова из плана распознавания, тег пуст. - plan, tag, err := w.effectivePlan(context.Background(), "1") + plan, prov, pid, err := w.effectivePlan(context.Background(), "1") if err != nil { t.Fatalf("effectivePlan: %v", err) } if plan.Title != "Догадка" || plan.Year != 2000 { t.Errorf("plan = %q (%d), want plan-defaults", plan.Title, plan.Year) } - if tag != "" { + if tag := providerTag(prov, pid); tag != "" { t.Errorf("tag = %q, want empty", tag) } if ov := st.overrides["1"]; ov[ovrTitle] != "" || ov[ovrYear] != "" { @@ -1472,14 +1707,14 @@ func TestAddManualSource_ChoosesAndClears(t *testing.T) { if len(st.candidates) != 2 { t.Fatalf("candidates = %d, want 2 (manual added)", len(st.candidates)) } - plan, tag, err := w.effectivePlan(context.Background(), "1") + plan, prov, pid, err := w.effectivePlan(context.Background(), "1") if err != nil { t.Fatalf("effectivePlan: %v", err) } if plan.Title != "Догадка" { // безтайтловый источник → название из плана t.Errorf("plan title = %q, want plan-default", plan.Title) } - if tag != "tmdbid-603" { + if tag := providerTag(prov, pid); tag != "tmdbid-603" { t.Errorf("tag = %q", tag) } } @@ -1627,7 +1862,7 @@ func TestToLayoutPlan(t *testing.T) { {Src: "sample.mkv", Role: "sample"}, }, } - lp := toLayoutPlan(plan, "/d", "tmdbid-1") + lp := toLayoutPlan(plan, "/d", "tmdbid-1", "") if len(lp.Files) != 1 { t.Fatalf("want 1 linkable file, got %d", len(lp.Files)) } @@ -1669,7 +1904,7 @@ func TestToLayoutPlan_SrcPrefixIsSavePath(t *testing.T) { {Src: tc.src, Role: recognize.RoleMain, Season: &s, Episode: &e}, }, } - lp := toLayoutPlan(plan, savePath, "") + lp := toLayoutPlan(plan, savePath, "", "") if len(lp.Files) != 1 { t.Fatalf("want 1 file, got %d", len(lp.Files)) } diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 168a584..dfed43f 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -74,6 +74,9 @@ type Store interface { ListOverrides(ctx context.Context, downloadID string) (map[string]string, error) CreateFileLinks(ctx context.Context, links []store.FileLink) error SupersedeForeignLinks(ctx context.Context, downloadID string, dstPaths []string) error + // LiveTitleFolders — dst_path живых ссылок загрузок того же (provider, + // provider_id), кроме excludeDownloadID (правило сходимости папки). + LiveTitleFolders(ctx context.Context, provider, providerID, excludeDownloadID string) ([]string, error) LatestBatchID(ctx context.Context, downloadID string) (string, error) ListFileLinksByBatch(ctx context.Context, batchID string) ([]store.FileLink, error) DeleteFileLinksByBatch(ctx context.Context, batchID string) error @@ -110,6 +113,9 @@ type Layouter interface { Apply(ctx context.Context, links []layout.Link) ([]layout.Result, error) Undo(ctx context.Context, links []layout.Link) (int, error) Remove(ctx context.Context, links []layout.Link) (int, error) + // TitleFolder разбирает dst_path в папку тайтла и базу имени (правило + // сходимости папки). ok=false, если путь не под корнем нужной библиотеки. + TitleFolder(t layout.MediaType, dst string) (dir, base string, ok bool) } // NotifyEvent — повод позвать пользователя. diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index efc09d3..8a2b38b 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -235,6 +235,9 @@ func (f *fakeStore) ListOverrides(_ context.Context, _ string) (map[string]strin return nil, nil } func (f *fakeStore) CreateFileLinks(_ context.Context, _ []store.FileLink) error { return nil } +func (f *fakeStore) LiveTitleFolders(_ context.Context, _, _, _ string) ([]string, error) { + return nil, nil +} func (f *fakeStore) SupersedeForeignLinks(_ context.Context, _ string, _ []string) error { return nil } diff --git a/openspec/changes/archive/2026-07-10-series-folder-convergence/.openspec.yaml b/openspec/changes/archive/2026-07-10-series-folder-convergence/.openspec.yaml new file mode 100644 index 0000000..eb5fa80 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-series-folder-convergence/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-10 diff --git a/openspec/changes/archive/2026-07-10-series-folder-convergence/design.md b/openspec/changes/archive/2026-07-10-series-folder-convergence/design.md new file mode 100644 index 0000000..d4e7876 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-series-folder-convergence/design.md @@ -0,0 +1,163 @@ +## Context + +Раскладка строит целевой путь `layout.BuildLinks(Plan)`: из `Plan.Title`/`Year` +собирается база `Название (Год)`, к ней провайдер-тег → папка, и та же база идёт +в имена файлов (`movieDst`/`seriesDst` через `titleYear`/`episodeStem`). План +раскладки собирается в `internal/worker`: `effectivePlan(id)` даёт +`recognize.Plan` (с применёнными override) и провайдер-тег, `linkPlan` зовёт +`toLayoutPlan` + `BuildLinks` + `Apply` и двигает FSM. + +Проблема — база печатается заново из выхода распознавания на каждой раскладке; +совпадение `(provider, provider_id)` не даёт совпадения строки папки (см. +`docs/drafts/logical-title-model.md` §3). Решение (§5.2 черновика) — правило +сходимости: при подтверждённом матче наследовать базу от живых ссылок того же +тайтла. Отдельная сущность «тайтл» не вводится (§4, §7 — решено). + +## Goals / Non-Goals + +**Goals:** +- Второй/последующий вклад в тайтл с тем же `(provider, provider_id)` ложится в + ту же папку, что и живые ссылки, включая совпадение баз в именах файлов. +- Якорь — живой `dst_path` (истина на диске), не поля распознавания. +- Рассинхрон (≥2 разных живых папок одного матча) → review, не тихий выбор. +- Никаких изменений схемы БД и инвариантов безопасности данных. + +**Non-Goals:** +- Merge-докачка (доложить недостающее) — отдельный change (§6.2 черновика). +- Группировка «тайтл» в UI и удаление целиком — отдельный change (§6.4). +- Склейка при смене провайдера между сезонами — осознанно не решаем (§6.1). +- Сходимость без матча (`provider=none`) — не автоматизируем (инвариант). +- **In-app разрешение рассинхрона** (выбор/сведение среди расходящихся живых + папок через UI) — вне scope: рассинхрон редкий (pre-existing до внедрения), + remedy — ручное переименование папки на диске, после чего сверка убирает + лишний якорь; review показывает причину. Команду-сведение вводить не будем. + +## Decisions + +### D1. Якорь — из `dst_path` живых ссылок, а «живость» — по диску + +Базу берём из существующего целевого пути ссылки, а не из +`recognition.title/year` загрузки-якоря. Причина: путь на диске — единственная +истина о том, где реально лежит папка; поля recognition при ручном +переименовании указывали бы в пустоту. + +**Важно (правка по ревью дизайна):** статус ссылки в БД — НЕ признак живости +папки. `target_missing` — состояние **загрузки**, а не статус **ссылки**: при +переименовании/удалении папки строки `file_link` остаются `linked/copied/exists` +(они нужны для самовосстановления в `done`), а `dst_path` уже указывает в +пустоту; загрузка уйдёт в `target_missing` лишь на следующем тике сверки +(`reconcile.targetPresent` определяет присутствие динамически через `os.Lstat`). +Поэтому кандидаты отбираем по статусу ссылки + матчу (дешёвый SQL), но перед +использованием как якорь **проверяем существование папки тайтла на диске** +(`os.Lstat`) — в духе `targetPresent`. Отсутствующая на диске папка в якоря не +идёт. Это устойчиво к любым будущим статусам и к лагу сверки, ценой одного stat +на кандидата (кандидатов единицы). + +_Альтернатива (отвергнута):_ фильтровать в SQL по состоянию загрузки +(`done`/`orphaned` in; `target_missing`/`deleted`/… out) — работает, но зависит +от тика сверки (окно рассинхрона БД↔диск) и хрупко к добавлению новых состояний. +_Альтернатива (отвергнута):_ наследовать `recognition.title/year` — расходится с +диском при переименовании и требует знать override'ы якоря. + +### D2. Извлечение базы из пути + +Из `dst_path` берём **папку тайтла** — первый компонент под корнем библиотеки +(`series`/`movies`): обрезка корня + первый сегмент относительного пути. Считается +одинаково для сериала (`root/Папка/Season NN/файл`) и фильма (`root/Папка/файл`). + +База получается снятием **хвостового provider-тега** ` [...]` (а не сверкой с +текущим тегом). Так надёжнее к граничному случаю (правка по ревью): если +загрузку-якорь переоценили на другой `provider_id` и не переразложили, её папка +несёт **старый** тег, а текущий тег уже иной — сверка «снять именно текущий тег» +не нашла бы суффикс и вернула бы базу с застрявшим тегом (потом двойной тег). +Снятие любого хвостового ` [...]` даёт чистую базу; текущий тег добавляется при +построении папки как обычно. Полученная база — уже санитизированная строка с +диска, повторный `sanitizeComponent` идемпотентен. + +Fallback: если сегмент не под корнем, папку не удалось выделить, или база после +снятия тега пуста — кандидат не считается якорем (печатаем из распознавания), а +не даём искажённую базу. + +### D3. Проброс унаследованной базы в layout + +В `layout.Plan` добавляется опциональное поле `FolderBase string`. Пусто — +поведение как прежде (`base = titleYear(Title, Year)`). Непусто — `base` берётся +из `FolderBase` (после `sanitizeComponent`), и эта база идёт и в папку, и в имена +файлов. Провайдер-тег складывается как прежде из `ProviderTag`. Так `layout` +остаётся «глупой» — про сходимость ничего не знает, лишь принимает готовую базу; +семантика (lookup, рассинхрон) — в worker. + +_Альтернатива (отвергнута):_ реверс базы в `Title`+`Year` — неоднозначно, если +название само оканчивается на `(NNNN)`. + +### D4. Точка внедрения — внутри linkPlan (состояние linking) + +Разрешение якоря — read-операция, но уход в `review` при рассинхроне делаем из +состояния `linking` (внутри `linkPlan`, рядом с обработкой коллизии), НЕ до +claim. Причина (правка по второму ревью): apply вызывается из `review` **и +`deferred`**, а ребра `deferred → review` в графе FSM (`allowedTransitions`) +нет — прямой desync-переход до claim из `deferred` был бы отклонён графом. +Коллизия уже решает это тем же способом: claim `linking`, затем `linking → +review` (это ребро легально). Проверка сходимости встаёт туда же, ценой лишней +claim-записи в редком desync-случае (как у коллизии) — осиротевший `linking` +исключён, т.к. переход синхронный под `w.mu`. + +Чистый хелпер worker (без побочных эффектов, только чтение БД+ФС): +`resolveFolderBase(ctx, downloadID, provider, providerID, mediaType) (base string, desync bool, err error)` +1. матч не подтверждён (`provider`/`provider_id` пусты/`none`) → `("", false, nil)`; +2. иначе store-метод `LiveTitleFolders(ctx, provider, providerID, excludeDownloadID)` + возвращает `dst_path` ссылок со статусом `linked/copied/exists` загрузок с тем + же `(provider, provider_id)` current recognition, кроме текущей; +3. worker сводит к **различным** папкам тайтла (D2), отбрасывая несуществующие на + диске (`os.Lstat`, D1) и неразбираемые (D2 fallback): + - 0 → `("", false, nil)` — печатаем из распознавания; + - 1 → `(база, false, nil)`; + - ≥2 → `("", true, nil)` — рассинхрон. + +Реакция: +- `linkPlan` (авто и ручное «Применить»): `desync` → `transition(review, + "title_folder_desync")` из `linking`, раскладку не выполняем; иначе строим план + с `base`. +- **Превью** на ревью (D5): тот же хелпер напрямую; `desync` → показываем базу из + распознавания (информационно, без перевода в review), иначе — унаследованную. + +Store-метод отдаёт сырые `dst_path` (снятие тега и проверка диска — в worker, где +известны корни `movies`/`series`). Store не знает про layout-именование. + +### D5. Область — auto, manual apply и превью едины + +`linkPlan` — общий путь для авто-раскладки и ручного «Применить»; оба зовут +`resolveFolderBase`. Превью раскладки в review (`toLayoutPlan`+`BuildLinks` для +показа, review.go:921/981) зовёт тот же хелпер и наследует базу — иначе +нарушился бы инвариант «превью = применение» (реши́ли по ревью дизайна): +пользователь увидел бы `Fargo (2017)/…`, а «Применить» дал бы `Фарго (2014)/…`. +Разрешение чистое, поэтому переиспользуется без риска побочных эффектов; +рассинхрон в превью не переводит задачу в review (это делает только применение). + +## Risks / Trade-offs + +- **Гонка двух загрузок одного тайтла без якоря** (оба печатают базу + одновременно, LLM дал разные строки) → две папки, дальше рассинхрон → review. + → Митигация: worker сериализует раскладку под единой блокировкой (`w.mu`); + вторая уже увидит живой якорь первой. Полностью не исключено при параллельном + первом заведении — приемлемо (редко), ловится рассинхроном. +- **Смена провайдера между сезонами** (§6.1) — якорь по `(provider, provider_id)` + не склеит → новая папка/через review. → Осознанная принятая цена. +- **Парсинг пути** (снятие тега/корня) — хрупок к нестандартным путям. → + Митигация: путь строит сам layout по фиксированной схеме; извлечение обратной + операцией по тем же корню/тегу. Если сегмент не под корнем — трактуем как «нет + якоря» (safe: печатаем из распознавания), не падаем. + +## Migration Plan + +Изменение чистое (новый SELECT + опц. поле плана), схему БД не трогает, +обратной миграции данных не требует. Уже разложенные до внедрения тайтлы с одной +живой папкой сразу получают сходимость; с несколькими — рассинхрон-review при +следующем вкладе (штатно). Откат — обычный откат коммита. + +## Open Questions + +- Политика склейки при смене провайдера — отложена (§10 черновика), вне scope. + +_Решено по ревью дизайна:_ превью выравниваем с наследованием (D5); in-app +разрешение рассинхрона — вне scope, ручной фикс на диске (Non-Goals). diff --git a/openspec/changes/archive/2026-07-10-series-folder-convergence/proposal.md b/openspec/changes/archive/2026-07-10-series-folder-convergence/proposal.md new file mode 100644 index 0000000..54eb66f --- /dev/null +++ b/openspec/changes/archive/2026-07-10-series-folder-convergence/proposal.md @@ -0,0 +1,63 @@ +## Why + +Второй/третий сезон одного сериала должен ложиться в ТУ ЖЕ папку, а не заводить +рядом почти одинаковую. Сейчас имя папки печатается заново из выхода LLM при +каждой раскладке (`layout.BuildLinks` из полей плана), и совпадение +`(provider, provider_id)` не гарантирует совпадение строки папки: LLM может дать +«Fargo» и «Фарго», год сезона вместо года сериала — и верно заматченный второй +сезон уедет в соседнюю папку. Это ядро «проблемы второго сезона»: она **не про +группировку, а про сходимость папки** (см. `docs/drafts/logical-title-model.md` +§3, §5.2). + +## What Changes + +- Вводим **правило сходимости папки** при построении плана раскладки: при + подтверждённом матче (`provider` и `provider_id` заданы) база папки + (`Название (Год)`) **наследуется** от живых `file_link` других загрузок того + же тайтла, а выход LLM для папки игнорируется. +- Якорь берём из **существующего `dst_path`** живых ссылок (`status IN + linked/copied/exists`) загрузок, чей current recognition имеет тот же + `(provider, provider_id)` — живые пути — истина (переименовали папку → сверка + уводит ссылки в `target_missing`, якорь исчезает, следующая загрузка печатает + заново). +- Унаследованная база применяется **и к папке, и к именам файлов внутри** + (episode/movie stem), чтобы `Fargo (2014) S02E01` совпадало с + `Fargo (2014) S01E01`. Провайдер-тег `[tvdbid-…]` по-прежнему берётся из + текущего `(provider, provider_id)`. +- **Рассинхрон** (несколько РАЗНЫХ живых папок с одним `(provider, provider_id)`, + случившийся до внедрения правила) → загрузка уходит в **review** с явной + причиной, якорь молча не выбираем. +- Отдельная сущность «тайтл» **не вводится**: это join по существующим таблицам + (`file_link → download → recognition(is_current)`), без новых + сущностей/таблиц. Схема БД не меняется. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `file-layout`: добавляется правило сходимости базы папки при построении + целевого пути — при подтверждённом матче база наследуется от живых ссылок того + же `(provider, provider_id)`, а не печатается из выхода распознавания; несколько + расходящихся живых папок → уход в review с причиной «рассинхрон» вместо тихого + выбора (по прецеденту коллизии, которая тоже уводит в review из file-layout). + +## Impact + +- **Код:** `internal/layout` (возможность построить план с унаследованной базой + папки), `internal/worker` (`linkPlan`/построение плана раскладки: lookup якоря, + ветка рассинхрон→review), `internal/store` (новый read-метод: живые целевые + папки по `(provider, provider_id)`, исключая текущую загрузку). +- **Инварианты безопасности данных:** не трогаем. `download` остаётся мостом, + выход распознавания недоверенный, безопасность — на санитизации и проверке + пути под библиотекой. Никаких новых прав на источник. +- **Схема БД:** без изменений (только новый SELECT-join). +- **Границы:** нет якоря → папка из распознавания (как сейчас); нет матча + (`provider=none`) → авто-раскладки нет, всё через review (инвариант); смена + провайдера между сезонами якорь не склеит — редкий случай, штатно review + (осознанная цена, черновик §6.1). +- **Совместимость:** поведение первой загрузки тайтла не меняется; правило + влияет только на последующие вклады при наличии живого якоря. diff --git a/openspec/changes/archive/2026-07-10-series-folder-convergence/specs/file-layout/spec.md b/openspec/changes/archive/2026-07-10-series-folder-convergence/specs/file-layout/spec.md new file mode 100644 index 0000000..7df2471 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-series-folder-convergence/specs/file-layout/spec.md @@ -0,0 +1,85 @@ +## ADDED Requirements + +### Requirement: Сходимость базы папки при подтверждённом матче + +Система SHALL при построении плана раскладки для загрузки с подтверждённым матчем (заданы `provider` и `provider_id`) наследовать **базу имени** (`Название (Год)` — строку без provider-тега) от существующей на диске папки-якоря того же тайтла, а НЕ печатать её заново из выхода распознавания. Кандидаты +в якорь — целевые пути (`dst_path`) ссылок со статусом `linked`/`copied`/`exists` +**других** загрузок (не текущей), чей current recognition имеет тот же +`(provider, provider_id)`. + +Истина — папка на диске, а не запись в БД: кандидат SHALL считаться живым +якорем, только если его папка тайтла реально существует на файловой системе. +Статус ссылки в БД недостаточен — при ручном/Jellyfin-переименовании папки +запись `file_link` какое-то время остаётся `linked` (загрузка лишь позже уходит +в `target_missing` по сверке), а `dst_path` указывает на уже несуществующий путь. +Поэтому кандидат, чья папка тайтла отсутствует на диске, в якоря НЕ берётся; если +живых якорей не осталось, следующая загрузка снова печатает базу из распознавания. + +Унаследованная база SHALL применяться **и к папке сериала/фильма, и к именам +файлов внутри** (episode/movie stem), чтобы серии разных сезонов совпадали по +базе (`Fargo (2014) S02E01` рядом с `Fargo (2014) S01E01`). Provider-тег на папке +(`[tvdbid-…]`) по-прежнему SHALL строиться из текущего `(provider, provider_id)`. + +База тайтла извлекается из папки-якоря снятием хвостового provider-тега +` [...]`; извлечённая база прогоняется через ту же санитизацию, что и печатаемая. +Если папку-якорь нельзя разобрать (сегмент не под корнем библиотеки, база пуста), +кандидат в якоря НЕ берётся (безопасный fallback на печать из распознавания), а +не даёт искажённую базу. + +Наследование SHALL происходить только при подтверждённом матче. Нет живого якоря +(первая загрузка тайтла) → база печатается из распознавания, как прежде. Нет +матча (`provider` пуст / `none`) → авто-раскладки нет (инвариант), база не +наследуется — папку на ревью выбирает человек. Правило не вводит новых сущностей +и не меняет схему БД: это выборка по существующим `file_link → download → +recognition(is_current)` плюс проверка существования папки на диске. Безопасность +по-прежнему держится на санитизации и проверке пути под библиотекой, а не на +доверии к выходу распознавания. + +Разрешение базы SHALL быть единым для авто-раскладки и ручного «Применить», а +также для **предпросмотра** раскладки на ревью — чтобы превью показывало ту же +папку/имена, что даст применение (инвариант «превью = применение»). В +предпросмотре разрешение выполняется без побочных эффектов (в review из-за +рассинхрона переводит только применение, не показ). + +#### Scenario: Второй сезон ложится в папку первого + +- **GIVEN** первый сезон уже разложен в `series/Фарго (2014) [tvdbid-269613]/Season 01/…` (ссылки живые) +- **AND** новая загрузка со вторым сезоном имеет матч TVDB `269613`, но распознавание дало название «Fargo» и год `2017` +- **WHEN** строится план раскладки второго сезона +- **THEN** база наследуется от живого якоря: папка = `series/Фарго (2014) [tvdbid-269613]/` +- **AND** серия ложится как `Season 02/Фарго (2014) S02E01.mkv` (база в имени файла — унаследованная, а не из выхода LLM) + +#### Scenario: Нет живого якоря — печатаем из распознавания + +- **GIVEN** ни у одной загрузки нет живых ссылок с тем же `(provider, provider_id)` +- **WHEN** строится план раскладки при подтверждённом матче +- **THEN** база берётся из распознавания (название+год), как прежде — первая загрузка «печатает» имя папки + +#### Scenario: Нет матча — сходимость не применяется + +- **GIVEN** у загрузки нет подтверждённого матча (`provider` пуст / `none`) +- **WHEN** обрабатывается раскладка +- **THEN** авто-наследования базы не происходит, загрузка идёт через review (папку выбирает человек) + +#### Scenario: Папка-якорь переименована на диске — печатаем заново + +- **GIVEN** у загрузки-кандидата статус ссылок ещё `linked`, но её папка тайтла на диске переименована/удалена (по `dst_path` папки нет) +- **WHEN** строится план раскладки новой загрузки с тем же матчем +- **THEN** отсутствующая на диске папка в якоря не берётся +- **AND** при отсутствии других живых якорей база печатается из распознавания + +#### Scenario: Превью на ревью совпадает с применением + +- **GIVEN** есть живой якорь тайтла, а распознавание текущей загрузки дало иную базу +- **WHEN** на ревью открывается предпросмотр целевой раскладки +- **THEN** превью показывает папку/имена с унаследованной базой якоря — те же, что даст «Применить» + +### Requirement: Рассинхрон живых папок тайтла уходит в review + +Система MUST NOT молча выбирать якорь при обнаружении **нескольких РАЗНЫХ** живых целевых папок с одним `(provider, provider_id)` (рассинхрон, случившийся до внедрения правила сходимости): такая загрузка SHALL уходить в `review` с явной причиной «рассинхрон папок тайтла», чтобы человек выбрал/свёл папку вручную (по прецеденту коллизии, которая тоже уводит в review из раскладки). + +#### Scenario: Две живые папки одного матча → review + +- **GIVEN** для матча TVDB `269613` существуют две разные живые папки (`Фарго (2014) …` и `Fargo (2017) …`) +- **WHEN** строится план раскладки новой загрузки с этим матчем +- **THEN** раскладка не выполняется, задача переходит в `review` с причиной рассинхрона папок тайтла diff --git a/openspec/changes/archive/2026-07-10-series-folder-convergence/tasks.md b/openspec/changes/archive/2026-07-10-series-folder-convergence/tasks.md new file mode 100644 index 0000000..b807da1 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-series-folder-convergence/tasks.md @@ -0,0 +1,23 @@ +## 1. Layout: приём унаследованной базы и разбор пути + +- [x] 1.1 Добавить в `layout.Plan` опциональное поле `FolderBase string` (пусто → как прежде) +- [x] 1.2 В `BuildLinks`: если `FolderBase != ""` — использовать её как `base` (через `sanitizeComponent`) вместо `titleYear(Title, Year)`, применяя и к папке, и к именам файлов; тег складывать как прежде +- [x] 1.3 Экспортировать хелпер(ы): извлечь папку тайтла из `dst_path` (первый сегмент под корнем `movies`/`series`; не под корнем → пусто) и снять базу из папки (убрать хвостовой ` [...]`; пустой результат → пусто) +- [x] 1.4 Юнит-тесты layout: `FolderBase` перекрывает базу в папке и в именах файлов (movie и series); извлечение папки/базы из пути — с тегом, без тега, с чужим/старым тегом, не под корнем + +## 2. Store: lookup живых ссылок тайтла + +- [x] 2.1 Добавить `LiveTitleFolders(ctx, provider, providerID, excludeDownloadID) ([]string, error)`: `dst_path` ссылок со `status IN linked/copied/exists` загрузок с тем же `(provider, provider_id)` current recognition, кроме `excludeDownloadID`; пустой provider/id → пусто +- [x] 2.2 Юнит-тест store: возврат путей по матчу; исключение текущей загрузки; superseded/collision/удалённые не попадают; пустой provider/id → пусто + +## 3. Worker: правило сходимости, рассинхрон, превью + +- [x] 3.1 Чистый хелпер `resolveFolderBase(ctx, dl, provider, providerID, mediaType) (base string, desync bool, err error)`: нет матча → пусто; иначе `LiveTitleFolders` → свести к различным папкам тайтла (хелпер 1.3), отбросить несуществующие на диске (`os.Lstat`) и неразбираемые; 0 → пусто, 1 → база, ≥2 → desync. Без побочных эффектов. +- [x] 3.2 В `linkPlan` (из состояния `linking`, рядом с обработкой коллизии — `deferred→review` в графе нет): вызвать `resolveFolderBase`; `desync` → `transition(review, "title_folder_desync")`, раскладку не выполнять; иначе прокинуть `base` в `toLayoutPlan`/`layout.Plan` +- [x] 3.3 Превью раскладки на ревью (review.go ~921/981) зовёт тот же `resolveFolderBase` и наследует базу (инвариант «превью=применение»); `desync` в превью → база из распознавания, без перевода в review +- [x] 3.4 Тесты worker/integration: сходимость второго сезона в ту же папку; отсутствие якоря → свежая папка; переименование папки на диске у кандидата → не якорь (печать заново); смена провайдера → не склеивается; рассинхрон (2 живые папки) → review с причиной; превью совпадает с применением + +## 4. Спеки и проверка + +- [x] 4.1 `openspec validate --strict series-folder-convergence` +- [x] 4.2 `task test` и `task lint` — зелёные diff --git a/openspec/specs/file-layout/spec.md b/openspec/specs/file-layout/spec.md index 944fcf7..146da0f 100644 --- a/openspec/specs/file-layout/spec.md +++ b/openspec/specs/file-layout/spec.md @@ -144,3 +144,87 @@ SHALL перевести её в `review` с причиной. Повторно - **AND** созданные хардлинки остаются на диске - **AND** повторное «Применить» идемпотентно дописывает учёт и доводит до `done` +### Requirement: Сходимость базы папки при подтверждённом матче + +Система SHALL при построении плана раскладки для загрузки с подтверждённым матчем (заданы `provider` и `provider_id`) наследовать **базу имени** (`Название (Год)` — строку без provider-тега) от существующей на диске папки-якоря того же тайтла, а НЕ печатать её заново из выхода распознавания. Кандидаты +в якорь — целевые пути (`dst_path`) ссылок со статусом `linked`/`copied`/`exists` +**других** загрузок (не текущей), чей current recognition имеет тот же +`(provider, provider_id)`. + +Истина — папка на диске, а не запись в БД: кандидат SHALL считаться живым +якорем, только если его папка тайтла реально существует на файловой системе. +Статус ссылки в БД недостаточен — при ручном/Jellyfin-переименовании папки +запись `file_link` какое-то время остаётся `linked` (загрузка лишь позже уходит +в `target_missing` по сверке), а `dst_path` указывает на уже несуществующий путь. +Поэтому кандидат, чья папка тайтла отсутствует на диске, в якоря НЕ берётся; если +живых якорей не осталось, следующая загрузка снова печатает базу из распознавания. + +Унаследованная база SHALL применяться **и к папке сериала/фильма, и к именам +файлов внутри** (episode/movie stem), чтобы серии разных сезонов совпадали по +базе (`Fargo (2014) S02E01` рядом с `Fargo (2014) S01E01`). Provider-тег на папке +(`[tvdbid-…]`) по-прежнему SHALL строиться из текущего `(provider, provider_id)`. + +База тайтла извлекается из папки-якоря снятием хвостового provider-тега +` [...]`; извлечённая база прогоняется через ту же санитизацию, что и печатаемая. +Если папку-якорь нельзя разобрать (сегмент не под корнем библиотеки, база пуста), +кандидат в якоря НЕ берётся (безопасный fallback на печать из распознавания), а +не даёт искажённую базу. + +Наследование SHALL происходить только при подтверждённом матче. Нет живого якоря +(первая загрузка тайтла) → база печатается из распознавания, как прежде. Нет +матча (`provider` пуст / `none`) → авто-раскладки нет (инвариант), база не +наследуется — папку на ревью выбирает человек. Правило не вводит новых сущностей +и не меняет схему БД: это выборка по существующим `file_link → download → +recognition(is_current)` плюс проверка существования папки на диске. Безопасность +по-прежнему держится на санитизации и проверке пути под библиотекой, а не на +доверии к выходу распознавания. + +Разрешение базы SHALL быть единым для авто-раскладки и ручного «Применить», а +также для **предпросмотра** раскладки на ревью — чтобы превью показывало ту же +папку/имена, что даст применение (инвариант «превью = применение»). В +предпросмотре разрешение выполняется без побочных эффектов (в review из-за +рассинхрона переводит только применение, не показ). + +#### Scenario: Второй сезон ложится в папку первого + +- **GIVEN** первый сезон уже разложен в `series/Фарго (2014) [tvdbid-269613]/Season 01/…` (ссылки живые) +- **AND** новая загрузка со вторым сезоном имеет матч TVDB `269613`, но распознавание дало название «Fargo» и год `2017` +- **WHEN** строится план раскладки второго сезона +- **THEN** база наследуется от живого якоря: папка = `series/Фарго (2014) [tvdbid-269613]/` +- **AND** серия ложится как `Season 02/Фарго (2014) S02E01.mkv` (база в имени файла — унаследованная, а не из выхода LLM) + +#### Scenario: Нет живого якоря — печатаем из распознавания + +- **GIVEN** ни у одной загрузки нет живых ссылок с тем же `(provider, provider_id)` +- **WHEN** строится план раскладки при подтверждённом матче +- **THEN** база берётся из распознавания (название+год), как прежде — первая загрузка «печатает» имя папки + +#### Scenario: Нет матча — сходимость не применяется + +- **GIVEN** у загрузки нет подтверждённого матча (`provider` пуст / `none`) +- **WHEN** обрабатывается раскладка +- **THEN** авто-наследования базы не происходит, загрузка идёт через review (папку выбирает человек) + +#### Scenario: Папка-якорь переименована на диске — печатаем заново + +- **GIVEN** у загрузки-кандидата статус ссылок ещё `linked`, но её папка тайтла на диске переименована/удалена (по `dst_path` папки нет) +- **WHEN** строится план раскладки новой загрузки с тем же матчем +- **THEN** отсутствующая на диске папка в якоря не берётся +- **AND** при отсутствии других живых якорей база печатается из распознавания + +#### Scenario: Превью на ревью совпадает с применением + +- **GIVEN** есть живой якорь тайтла, а распознавание текущей загрузки дало иную базу +- **WHEN** на ревью открывается предпросмотр целевой раскладки +- **THEN** превью показывает папку/имена с унаследованной базой якоря — те же, что даст «Применить» + +### Requirement: Рассинхрон живых папок тайтла уходит в review + +Система MUST NOT молча выбирать якорь при обнаружении **нескольких РАЗНЫХ** живых целевых папок с одним `(provider, provider_id)` (рассинхрон, случившийся до внедрения правила сходимости): такая загрузка SHALL уходить в `review` с явной причиной «рассинхрон папок тайтла», чтобы человек выбрал/свёл папку вручную (по прецеденту коллизии, которая тоже уводит в review из раскладки). + +#### Scenario: Две живые папки одного матча → review + +- **GIVEN** для матча TVDB `269613` существуют две разные живые папки (`Фарго (2014) …` и `Fargo (2017) …`) +- **WHEN** строится план раскладки новой загрузки с этим матчем +- **THEN** раскладка не выполняется, задача переходит в `review` с причиной рассинхрона папок тайтла +