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

При подтверждённом матче база папки (имя+год) наследуется от живой
папки-якоря того же (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
+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{