Раскладка: сходимость папки сериала (второй сезон в ту же папку)

При подтверждённом матче база папки (имя+год) наследуется от живой
папки-якоря того же (provider, provider_id) вместо печати заново из выхода
LLM — так второй/последующий сезон ложится в ТУ ЖЕ папку, а не заводит
рядом почти одинаковую. Отдельная сущность «тайтл» не вводится.

- layout: Plan.FolderBase перекрывает базу в папке и именах файлов;
  TitleFolder разбирает dst_path в папку тайтла и базу (снятие тега).
- store: LiveTitleFolders — dst_path живых ссылок того же матча.
- worker: resolveFolderBase (живость якоря — по наличию папки на диске,
  os.Lstat, а не по статусу ссылки в БД) в linkPlan и в превью ревью
  (превью=применение); рассинхрон нескольких живых папок → review из
  linking (deferred→review в графе нет).

Схема БД не менялась. Change series-folder-convergence заархивирован,
требования влиты в openspec/specs/file-layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
av
2026-07-10 13:50:49 +03:00
co-authored by Claude Opus 4.8
parent 3f1a928000
commit 5c3ef79496
20 changed files with 1031 additions and 56 deletions
-1
View File
@@ -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) — Распознавание — ядро продукта, но смена модели или правка промпта сейчас вслепую…
@@ -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
+8 -1
View File
@@ -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 самодостаточны и закрывают главную
боль.
+5
View File
@@ -39,6 +39,11 @@ series/
- provider-id — на папке сериала.
- Сезоны — `Season 01`, файлы — `... SxxEyy`.
- **Сходимость папки:** при подтверждённом матче база папки (имя+год) наследуется
от живой папки-якоря того же `(provider, provider_id)` (существующей на диске), а
не печатается заново из выхода LLM — так второй сезон ложится в ту же папку, что
и первый. Несколько разных живых папок одного матча → review. Источник истины —
`openspec/specs/file-layout/` («Сходимость базы папки…»).
## Сопоставление источник → цель
+39 -2
View File
@@ -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
+72
View File
@@ -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{
+27
View File
@@ -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)
+19
View File
@@ -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)
+26
View File
@@ -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,
+60
View File
@@ -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")
+101 -29
View File
@@ -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)
+245 -10
View File
@@ -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))
}
+6
View File
@@ -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 — повод позвать пользователя.
+3
View File
@@ -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
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-10
@@ -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).
@@ -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).
- **Совместимость:** поведение первой загрузки тайтла не меняется; правило
влияет только на последующие вклады при наличии живого якоря.
@@ -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` с причиной рассинхрона папок тайтла
@@ -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` — зелёные
+84
View File
@@ -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` с причиной рассинхрона папок тайтла