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

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