package layout import ( "fmt" "path/filepath" "strings" ) // maxComponentBytes — предел длины одного компонента целевого пути в БАЙТАХ // UTF-8, а не в символах: ядро меряет NAME_MAX в байтах, и кириллическое // название упирается в предел вдвое раньше латинского той же длины в знаках. // Значение — NAME_MAX у ext4/xfs/btrfs; у ядра оно не выясняется, потому что // раскладка обязана отказать до обращения к диску (см. spec file-layout). // Цена обеих сторон: на ФС с меньшим пределом (часть зашифрованных) имя пройдёт // проверку и упрётся в ядро — останется сегодняшний failed; на ФС с бо́льшим мы // откажем строже, чем нужно. Лечение — правка этой константы, а не настройка: // значение, которое некому выставить осознанно, не гибкость. const maxComponentBytes = 255 // checkComponentLengths проверяет, что каждый компонент пути dst ПОД корнем // root помещается в maxComponentBytes. Корень не проверяется: его каталоги задаёт // оператор, и жаловаться на них раскладка не вправе. Возвращает ошибку, // обёртывающую ErrNameTooLong и называющую непомещающийся компонент и его длину. // Чистая функция: к диску не обращается. func checkComponentLengths(root, dst string) error { rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(dst)) if err != nil { return fmt.Errorf("layout: relative target %q: %w", dst, err) } for c := range strings.SplitSeq(rel, string(filepath.Separator)) { if len(c) > maxComponentBytes { return fmt.Errorf("%w: %q — %d байт при пределе %d", ErrNameTooLong, shorten(c), len(c), maxComponentBytes) } } return nil } // errNameSample — сколько рун непомещающегося имени показать в тексте ошибки. // Текст уезжает в error_msg, а оттуда в баннер ревью и в карточку Telegram: // имя целиком (а оно по условию длиннее 255 байт) заняло бы там весь экран. // Точную длину несёт число рядом, поэтому образца хватает, чтобы узнать имя. const errNameSample = 40 // shorten оставляет от имени начало и конец, выкидывая середину. Режет по рунам: // обрыв посреди многобайтовой буквы дал бы в сообщении мусор. func shorten(s string) string { r := []rune(s) if len(r) <= errNameSample { return s } head := errNameSample / 2 return string(r[:head]) + "…" + string(r[len(r)-head:]) } // 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)) }