// Package layout раскладывает распознанные файлы по конвенциям Jellyfin // хардлинками, не трогая исходную раздачу (см. docs/specs/jellyfin-layout.md). // // Инварианты безопасности (см. architecture.md → «Раскладка файлов»): // - линкуем только файлы; целевые каталоги создаём mkdir; // - целевое имя санитизируется, итоговый путь обязан быть строго под // paths.movies/paths.series — иначе отказ (защита от traversal); // - существующее не перезаписываем: тот же inode → идемпотентно «готово», // другой файл → коллизия (review); // - источник неприкосновенен: undo удаляет только ссылки своего батча и // только под библиотекой. package layout import ( "context" "errors" "fmt" "io/fs" "os" "path/filepath" "syscall" ) // MediaType — вид контента. type MediaType string const ( Movie MediaType = "movie" Series MediaType = "series" ) // Role — роль файла. Линкуются только main/episode/subtitle; остальные // (extra/sample/ignore) раскладка пропускает. type Role string const ( RoleMain Role = "main" RoleEpisode Role = "episode" RoleSubtitle Role = "subtitle" ) // Kind — вид целевой ссылки (для file_link.kind). type Kind string const ( KindVideo Kind = "video" KindSubtitle Kind = "subtitle" ) // PlanFile — один файл к раскладке. type PlanFile struct { Src string // абсолютный путь источника (content dir + относительное имя) Role Role Season *int // для сериала Episode *int // для сериала EpisodeEnd *int // двойная серия SxxEyy-Ezz (опц.) Lang string // язык субтитров (опц.) Flags []string // флаги субтитров: forced/sdh/... (опц.) } // Plan — что и куда раскладывать. type Plan struct { Type MediaType Title string Year int ProviderTag string // напр. "tmdbid-693134"; пусто — без тега Files []PlanFile } // Link — посчитанная пара источник → цель. type Link struct { Src string Dst string Kind Kind } // Config — корни библиотек и режим каталогов. type Config struct { MoviesDir string SeriesDir string DirMode os.FileMode // 0 → 0755 } // Layouter строит и применяет раскладку. type Layouter struct { movies string series string dirMode os.FileMode } // New собирает раскладчик. Корни нормализуются (filepath.Clean). func New(cfg Config) (*Layouter, error) { if cfg.MoviesDir == "" || cfg.SeriesDir == "" { return nil, fmt.Errorf("layout: movies/series dirs required") } mode := cfg.DirMode if mode == 0 { mode = 0o755 } return &Layouter{ movies: filepath.Clean(cfg.MoviesDir), series: filepath.Clean(cfg.SeriesDir), dirMode: mode, }, nil } // root возвращает корень библиотеки для типа. func (l *Layouter) root(t MediaType) (string, error) { switch t { case Movie: return l.movies, nil case Series: return l.series, nil default: return "", fmt.Errorf("layout: неизвестный тип %q", t) } } // BuildLinks вычисляет целевые пути (без обращения к ФС, кроме отсутствия — // чистая функция от плана). Файлы-роли вне main/episode/subtitle // пропускаются. Любая невалидность (пустое название, серия без номера, // выход за пределы библиотеки) — ошибка целиком, частичную раскладку не // начинаем. func (l *Layouter) BuildLinks(p Plan) ([]Link, error) { root, err := l.root(p.Type) if err != nil { return nil, err } base, err := titleYear(p.Title, p.Year) if err != nil { return nil, err } folder := folderName(base, p.ProviderTag) var links []Link for i := range p.Files { f := &p.Files[i] var dst string var kind Kind var berr error switch p.Type { case Movie: dst, kind, berr = l.movieDst(root, folder, base, f) case Series: dst, kind, berr = l.seriesDst(root, folder, base, f) } if berr != nil { return nil, berr } if dst == "" { continue // роль не линкуется (extra/sample/ignore) } if !underRoot(root, dst) { return nil, fmt.Errorf("layout: цель %q вне библиотеки %q (файл %q)", dst, root, f.Src) } links = append(links, Link{Src: f.Src, Dst: dst, Kind: kind}) } if len(links) == 0 { return nil, fmt.Errorf("layout: план не дал ни одной ссылки") } return links, nil } func (l *Layouter) movieDst(root, folder, base string, f *PlanFile) (string, Kind, error) { dir := filepath.Join(root, folder) switch f.Role { case RoleMain: return filepath.Join(dir, base+ext(f.Src)), KindVideo, nil case RoleSubtitle: name := base + subtitleSuffix(f.Lang, f.Flags) + ext(f.Src) return filepath.Join(dir, name), KindSubtitle, nil default: return "", "", nil } } func (l *Layouter) seriesDst(root, folder, base string, f *PlanFile) (string, Kind, error) { if f.Role != RoleEpisode && f.Role != RoleSubtitle { return "", "", nil } if f.Season == nil || f.Episode == nil { return "", "", fmt.Errorf("layout: файл %q без season/episode", f.Src) } episodeEnd := 0 if f.EpisodeEnd != nil { episodeEnd = *f.EpisodeEnd } dir := filepath.Join(root, folder, seasonFolder(*f.Season)) stem := episodeStem(base, *f.Season, *f.Episode, episodeEnd) switch f.Role { case RoleEpisode: return filepath.Join(dir, stem+ext(f.Src)), KindVideo, nil case RoleSubtitle: name := stem + subtitleSuffix(f.Lang, f.Flags) + ext(f.Src) return filepath.Join(dir, name), KindSubtitle, nil default: return "", "", nil } } // LinkStatus — исход создания одной ссылки. type LinkStatus string const ( StatusLinked LinkStatus = "linked" // ссылка создана StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно StatusCollision LinkStatus = "collision" // цель занята другим файлом ) // Result — итог по одной ссылке. type Result struct { Link Link Status LinkStatus } // ErrCollision — цель существует и это другой файл (нужен review). var ErrCollision = errors.New("layout: target collision") // Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя // доводит начатое. При коллизии (цель занята чужим файлом) возвращает // ErrCollision, не перезаписывая. EXDEV (разные ФС) — явная ошибка. func (l *Layouter) Apply(_ context.Context, links []Link) ([]Result, error) { results := make([]Result, 0, len(links)) for _, ln := range links { root := l.movies if !underRoot(l.movies, ln.Dst) { root = l.series } if !underRoot(root, ln.Dst) { return results, fmt.Errorf("layout: цель %q вне библиотек", ln.Dst) } if err := os.MkdirAll(filepath.Dir(ln.Dst), l.dirMode); err != nil { return results, fmt.Errorf("layout: mkdir %q: %w", filepath.Dir(ln.Dst), err) } status, err := linkOne(ln.Src, ln.Dst) if err != nil { return results, err } results = append(results, Result{Link: ln, Status: status}) } return results, nil } // linkOne создаёт одну ссылку, разбирая «уже существует». func linkOne(src, dst string) (LinkStatus, error) { err := os.Link(src, dst) if err == nil { return StatusLinked, nil } if errors.Is(err, fs.ErrExist) { same, serr := sameFile(src, dst) if serr != nil { return "", fmt.Errorf("layout: stat existing %q: %w", dst, serr) } if same { return StatusExists, nil // идемпотентно: тот же inode } return StatusCollision, fmt.Errorf("%w: %q занят другим файлом", ErrCollision, dst) } if errors.Is(err, syscall.EXDEV) { return "", fmt.Errorf("layout: hardlink через границу ФС (%q → %q): %w", src, dst, err) } return "", fmt.Errorf("layout: link %q → %q: %w", src, dst, err) } // sameFile сообщает, указывают ли src и dst на один inode. func sameFile(src, dst string) (bool, error) { si, err := os.Stat(src) if err != nil { return false, err } di, err := os.Stat(dst) if err != nil { return false, err } return os.SameFile(si, di), nil } // Undo удаляет ссылки и подчищает опустевшие каталоги. Снимает только пути // строго под библиотеками (источник недосягаем). Отсутствующая цель — не // ошибка (идемпотентно). Возвращает число удалённых ссылок. func (l *Layouter) Undo(_ context.Context, links []Link) (int, error) { removed := 0 for _, ln := range links { root := l.movies if !underRoot(l.movies, ln.Dst) { root = l.series } if !underRoot(root, ln.Dst) { return removed, fmt.Errorf("layout: undo вне библиотеки: %q", ln.Dst) } if err := os.Remove(ln.Dst); err != nil { if errors.Is(err, fs.ErrNotExist) { continue } return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err) } removed++ pruneEmptyDirs(filepath.Dir(ln.Dst), root) } return removed, nil } // pruneEmptyDirs удаляет опустевшие каталоги вверх до (не включая) root. // Ошибки игнорируются: непустой каталог os.Remove не удалит — это и нужно. func pruneEmptyDirs(dir, root string) { for dir != root && underRoot(root, dir) { if err := os.Remove(dir); err != nil { return // непустой или нет прав — останавливаемся } dir = filepath.Dir(dir) } }