// Package layout раскладывает распознанные файлы по конвенциям Jellyfin // хардлинками, не трогая исходную раздачу (см. openspec/specs/file-layout/spec.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" "strings" "syscall" "git.vakhrushev.me/av/jellybit/internal/logctx" ) // 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"; пусто — без тега // FolderBase — унаследованная от живого якоря база имени ("Название (Год)"). // Пусто → база печатается из Title/Year (первая загрузка тайтла). Непусто → // перекрывает Title/Year и идёт и в папку, и в имена файлов (правило // сходимости папки, см. file-layout spec). Provider-тег добавляется отдельно // из ProviderTag. FolderBase string 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 := planBase(p) 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 } } // 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 const ( StatusLinked LinkStatus = "linked" // хардлинк создан StatusCopied LinkStatus = "copied" // хардлинк невозможен — файл скопирован (фолбэк) StatusExists LinkStatus = "exists" // уже была (тот же inode) — идемпотентно StatusCollision LinkStatus = "collision" // цель занята другим файлом StatusSuperseded LinkStatus = "superseded" // путь перехватила другая загрузка (см. state-reconciliation, владение путём) ) // Result — итог по одной ссылке. type Result struct { Link Link Status LinkStatus Size int64 // размер разложенного файла (байт); 0, если stat не удался } // ErrCollision — цель существует и это другой файл (нужен review). var ErrCollision = errors.New("layout: target collision") // ErrLastCopy — отказ снять ссылку, которая осталась последней копией данных // (источник недоступен или nlink<=1). Undo снимает лишний хардлинк, а не // единственный файл (см. state-reconciliation, инвариант безопасного Undo). var ErrLastCopy = errors.New("layout: refusing to remove last remaining copy") // Apply создаёт хардлинки по ссылкам. Идемпотентно: повтор после сбоя // доводит начатое. При коллизии (цель занята чужим файлом) возвращает // ErrCollision, не перезаписывая. Если хардлинк невозможен (разные ФС или ФС // не поддерживает link) — фолбэк на копирование файла с предупреждением в лог. func (l *Layouter) Apply(ctx context.Context, links []Link) ([]Result, error) { log := logctx.FromOr(ctx, l.log) 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(log, ln.Src, ln.Dst) if err != nil { log.Error("layout link failed", "src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "error", err) return results, err } log.Debug("layout link applied", "src", ln.Src, "dst", ln.Dst, "kind", ln.Kind, "status", status) // Размер разложенного файла для показа в веб-UI (фолбэк размера раздачи, // когда торрента нет в снимке). Best-effort: файл только что слинкован/ // скопирован/уже существовал — stat должен пройти; сбой не валит // раскладку, размер остаётся 0. var size int64 if fi, serr := os.Stat(ln.Dst); serr == nil { size = fi.Size() } else { log.Debug("layout stat size failed", "dst", ln.Dst, "error", serr) } results = append(results, Result{Link: ln, Status: status, Size: size}) } return results, nil } // linkOne создаёт одну ссылку, разбирая «уже существует» и невозможность // хардлинка (фолбэк на копирование). func (l *Layouter) linkOne(log *slog.Logger, 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 выше). log.Warn("layout hardlink unsupported, file copy fallback", "src", src, "dst", dst, "error", 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 удаляет ссылки и подчищает опустевшие каталоги. Снимает только пути // строго под библиотеками (источник недосягаем). Отсутствующая цель — не // ошибка (идемпотентно). Возвращает число удалённых ссылок. // // Защита от потери данных: сперва предпроверка всего батча — если хоть одна // существующая цель оказывается последней копией (источник пропал или // nlink<=1), весь Undo отклоняется с ErrLastCopy и НИ ОДНА ссылка не // снимается (иначе частичный откат стёр бы часть данных). Так откат снимает // лишний хардлинк, а не единственный файл. func (l *Layouter) Undo(ctx context.Context, links []Link) (int, error) { log := logctx.FromOr(ctx, l.log) // Предпроверка: путь под библиотекой + не последняя копия. for _, ln := range links { if _, err := undoRoot(l, ln.Dst); err != nil { return 0, err } fi, err := os.Lstat(ln.Dst) if err != nil { if errors.Is(err, fs.ErrNotExist) { continue // цели уже нет — снимать нечего (идемпотентно) } return 0, fmt.Errorf("layout: undo stat %q: %w", ln.Dst, err) } last, err := isLastCopy(fi, ln.Src) if err != nil { return 0, fmt.Errorf("layout: undo check %q: %w", ln.Dst, err) } if last { return 0, fmt.Errorf("%w: %q (источник %q недоступен)", ErrLastCopy, ln.Dst, ln.Src) } } removed := 0 for _, ln := range links { root, err := undoRoot(l, ln.Dst) if err != nil { return removed, err } if err := os.Remove(ln.Dst); err != nil { if errors.Is(err, fs.ErrNotExist) { continue } log.Error("layout undo remove failed", "dst", ln.Dst, "error", err) return removed, fmt.Errorf("layout: undo remove %q: %w", ln.Dst, err) } removed++ log.Debug("layout link removed", "dst", ln.Dst) pruneEmptyDirs(filepath.Dir(ln.Dst), root) } return removed, nil } // Remove снимает целевые ссылки БЕЗ гарда последней копии — в отличие от Undo, // который отказывается стирать единственную копию данных. Служит осознанному // удалению загрузки («освободить место»): снимает последнюю библиотечную ссылку, // даже если источник уже пропал. Как и Undo, трогает только пути строго под // библиотеками (источник недосягаем) и идемпотентен к отсутствующей цели. // Возвращает число удалённых ссылок. func (l *Layouter) Remove(ctx context.Context, links []Link) (int, error) { log := logctx.FromOr(ctx, l.log) removed := 0 for _, ln := range links { root, err := undoRoot(l, ln.Dst) if err != nil { return removed, err } if err := os.Remove(ln.Dst); err != nil { if errors.Is(err, fs.ErrNotExist) { continue // цели уже нет — снимать нечего (идемпотентно) } log.Error("layout remove failed", "dst", ln.Dst, "error", err) return removed, fmt.Errorf("layout: remove %q: %w", ln.Dst, err) } removed++ log.Debug("layout link removed (delete)", "dst", ln.Dst) pruneEmptyDirs(filepath.Dir(ln.Dst), root) } return removed, nil } // undoRoot возвращает корень библиотеки, под которым лежит dst, либо ошибку, // если путь не под movies/series (откат трогает только библиотеку). func undoRoot(l *Layouter, dst string) (string, error) { root := l.movies if !underRoot(l.movies, dst) { root = l.series } if !underRoot(root, dst) { return "", fmt.Errorf("layout: undo outside library: %q", dst) } return root, nil } // isLastCopy сообщает, является ли цель последней копией данных: исходный файл // уже не существует, либо у цели не осталось других жёстких ссылок (nlink<=1). // В обоих случаях unlink цели уничтожил бы единственную копию. func isLastCopy(fi os.FileInfo, src string) (bool, error) { if _, err := os.Lstat(src); err != nil { if errors.Is(err, fs.ErrNotExist) { return true, nil // источника нет — цель единственная копия } return false, fmt.Errorf("stat source %q: %w", src, err) } st, ok := fi.Sys().(*syscall.Stat_t) if !ok { return false, fmt.Errorf("unexpected stat type for %q", fi.Name()) } return st.Nlink <= 1, 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) } }