Раскладка: сходимость папки сериала (второй сезон в ту же папку)
При подтверждённом матче база папки (имя+год) наследуется от живой папки-якоря того же (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:
@@ -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
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user