401 lines
14 KiB
Go
401 lines
14 KiB
Go
// 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)
|
||
}
|
||
}
|