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

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