// 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" "io/fs" "log/slog" "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 // абсолютный путь источника (save_path + относительное имя) 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 log *slog.Logger } // New собирает раскладчик. Корни нормализуются (filepath.Clean). logger nil → // slog.Default(). func New(cfg Config, logger *slog.Logger) (*Layouter, error) { if cfg.MoviesDir == "" || cfg.SeriesDir == "" { return nil, fmt.Errorf("layout: movies/series dirs required") } mode := cfg.DirMode if mode == 0 { mode = 0o755 } if logger == nil { logger = slog.Default() } return &Layouter{ movies: filepath.Clean(cfg.MoviesDir), series: filepath.Clean(cfg.SeriesDir), dirMode: mode, log: logger, }, 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: unknown type %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: target %q is outside library %q (file %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: plan produced no links") } 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: file %q has no 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" // хардлинк создан StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк) 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, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС // не поддерживает link) — фолбэк на копирование файла с предупреждением в лог. 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: target %q is outside libraries", 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 := l.linkOne(ln.Src, ln.Dst) if err != nil { l.log.Error("layout: link failed", "src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "err", err) return results, err } l.log.Debug("layout: link applied", "src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status) results = append(results, Result{Link: ln, Status: status}) } return results, nil } // linkOne создаёт одну ссылку, разбирая «уже существует» и невозможность // хардлинка (фолбэк на копирование). func (l *Layouter) 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 is occupied by a different file", ErrCollision, dst) } if hardlinkUnsupported(err) { // Хардлинк невозможен (граница ФС или ФС без поддержки link). Не валим // раскладку — копируем файл и предупреждаем: диск дублируется, но // задача доходит до конца. dst здесь заведомо отсутствует (иначе был бы // fs.ErrExist выше). l.log.Warn("layout: hardlink unsupported, falling back to file copy", "src", src, "dst", dst, "err", err) if cerr := copyFile(src, dst); cerr != nil { return "", fmt.Errorf("layout: copy fallback %q → %q: %w", src, dst, cerr) } return StatusCopied, nil } return "", fmt.Errorf("layout: link %q → %q: %w", src, dst, err) } // hardlinkUnsupported сообщает, означает ли ошибка os.Link, что хардлинк между // этими путями в принципе невозможен (а не временный сбой): разные ФС (EXDEV) // или ФС без поддержки жёстких ссылок (ENOTSUP/EOPNOTSUPP, у части ФС — EPERM). func hardlinkUnsupported(err error) bool { return errors.Is(err, syscall.EXDEV) || errors.Is(err, syscall.ENOTSUP) || errors.Is(err, syscall.EOPNOTSUPP) || errors.Is(err, syscall.EPERM) } // copyFile копирует src в dst через временный файл в каталоге назначения и // атомарный rename — так сбой посреди копирования не оставит частичный файл на // месте dst. Источник не модифицируется. Вызывается, только когда хардлинк // невозможен (см. linkOne). func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return fmt.Errorf("open source: %w", err) } defer func() { _ = in.Close() }() info, err := in.Stat() if err != nil { return fmt.Errorf("stat source: %w", err) } tmp, err := os.CreateTemp(filepath.Dir(dst), ".jellybit-copy-*") if err != nil { return fmt.Errorf("create temp: %w", err) } tmpName := tmp.Name() cleanup := true defer func() { if cleanup { _ = os.Remove(tmpName) } }() if _, err := io.Copy(tmp, in); err != nil { _ = tmp.Close() return fmt.Errorf("copy data: %w", err) } if err := tmp.Sync(); err != nil { _ = tmp.Close() return fmt.Errorf("sync temp: %w", err) } if err := tmp.Close(); err != nil { return fmt.Errorf("close temp: %w", err) } if err := os.Chmod(tmpName, info.Mode().Perm()); err != nil { return fmt.Errorf("chmod temp: %w", err) } if err := os.Rename(tmpName, dst); err != nil { return fmt.Errorf("rename into place: %w", err) } cleanup = false return nil } // 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 outside library: %q", ln.Dst) } if err := os.Remove(ln.Dst); err != nil { if errors.Is(err, fs.ErrNotExist) { continue } l.log.Error("layout: undo remove failed", "dst", ln.Dst, "err", err) return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err) } removed++ l.log.Debug("layout: link removed", "dst", ln.Dst) 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) } }