Files
2026-06-14 19:37:09 +03:00

401 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}