316 lines
10 KiB
Go
316 lines
10 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/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)
|
|
}
|
|
}
|