Files
jellybit/internal/layout/name.go
T
avandClaude Opus 4.8 5c3ef79496 Раскладка: сходимость папки сериала (второй сезон в ту же папку)
При подтверждённом матче база папки (имя+год) наследуется от живой
папки-якоря того же (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>
2026-07-10 13:50:49 +03:00

125 lines
4.9 KiB
Go

package layout
import (
"fmt"
"path/filepath"
"strings"
)
// sanitizeComponent чистит один компонент пути (имя папки/файла): убирает
// разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает
// пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри
// сохраняются. Результат гарантированно не содержит '/', '\\', ".." целиком
// и не пуст (иначе ошибка у вызывающего).
func sanitizeComponent(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case r < 0x20 || r == 0x7f: // управляющие
b.WriteByte(' ')
case strings.ContainsRune(`/\:*?"<>|`, r): // разделители и недопустимые в SMB/NTFS
b.WriteByte(' ')
default:
b.WriteRune(r)
}
}
out := strings.Join(strings.Fields(b.String()), " ") // схлопнуть пробелы
out = strings.Trim(out, " .") // края: ни точек, ни пробелов
return out
}
// titleYear строит базу "Название (Год)" или "Название" при year == 0.
func titleYear(title string, year int) (string, error) {
t := sanitizeComponent(title)
if t == "" {
return "", fmt.Errorf("layout: empty title after sanitization (%q)", title)
}
if year > 0 {
return fmt.Sprintf("%s (%d)", t, year), nil
}
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)
if tag == "" {
return base
}
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)
}
// episodeStem — "Название (Год) S01E02"; при двойной серии episodeEnd>episode
// даёт "S01E02-E03".
func episodeStem(base string, season, episode, episodeEnd int) string {
if episodeEnd > episode {
return fmt.Sprintf("%s S%02dE%02d-E%02d", base, season, episode, episodeEnd)
}
return fmt.Sprintf("%s S%02dE%02d", base, season, episode)
}
// subtitleSuffix — ".ru", ".ru.forced" и т.п. (флаги после языка).
func subtitleSuffix(lang string, flags []string) string {
var b strings.Builder
if l := sanitizeComponent(lang); l != "" {
b.WriteByte('.')
b.WriteString(strings.ToLower(l))
}
for _, f := range flags {
if f = sanitizeComponent(f); f != "" {
b.WriteByte('.')
b.WriteString(strings.ToLower(f))
}
}
return b.String()
}
// ext возвращает расширение файла источника в нижнем регистре (с точкой).
func ext(src string) string {
return strings.ToLower(filepath.Ext(src))
}
// underRoot сообщает, лежит ли clean-путь p строго под каталогом root
// (после filepath.Clean у обоих). Защита от traversal: даже если имя
// прошло санитизацию, итог обязан остаться внутри библиотеки.
func underRoot(root, p string) bool {
root = filepath.Clean(root)
p = filepath.Clean(p)
if p == root {
return false // сам корень — не цель для файла
}
return strings.HasPrefix(p, root+string(filepath.Separator))
}