Раскладка файлов после распознавния
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
// Package layout — конвенции Jellyfin, санитизация путей, хардлинкер, undo.
|
||||
//
|
||||
// Заглушка: реализация в фазе Ф3 (см. docs/specs/jellyfin-layout.md).
|
||||
package layout
|
||||
@@ -0,0 +1,315 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func intp(n int) *int { return &n }
|
||||
|
||||
// fixture создаёт раскладчик с временными корнями downloads/movies/series и
|
||||
// одним исходным файлом.
|
||||
type fixture struct {
|
||||
l *Layouter
|
||||
downloads string
|
||||
movies string
|
||||
series string
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) fixture {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
downloads := filepath.Join(root, "downloads")
|
||||
movies := filepath.Join(root, "movies")
|
||||
series := filepath.Join(root, "series")
|
||||
for _, d := range []string{downloads, movies, series} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
l, err := New(Config{MoviesDir: movies, SeriesDir: series})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fixture{l: l, downloads: downloads, movies: movies, series: series}
|
||||
}
|
||||
|
||||
func (f fixture) srcFile(t *testing.T, rel, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(f.downloads, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestBuildLinks_Movie(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
src := f.srcFile(t, "The.Matrix/movie.mkv", "x")
|
||||
sub := f.srcFile(t, "The.Matrix/movie.ru.srt", "y")
|
||||
plan := Plan{
|
||||
Type: Movie, Title: "The Matrix", Year: 1999, ProviderTag: "tmdbid-603",
|
||||
Files: []PlanFile{
|
||||
{Src: src, Role: RoleMain},
|
||||
{Src: sub, Role: RoleSubtitle, Lang: "ru"},
|
||||
{Src: f.srcFile(t, "The.Matrix/sample.mkv", "z"), Role: "sample"},
|
||||
},
|
||||
}
|
||||
links, err := f.l.BuildLinks(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLinks: %v", err)
|
||||
}
|
||||
if len(links) != 2 { // sample пропущен
|
||||
t.Fatalf("want 2 links, got %d: %+v", len(links), links)
|
||||
}
|
||||
wantMain := filepath.Join(f.movies, "The Matrix (1999) [tmdbid-603]", "The Matrix (1999).mkv")
|
||||
wantSub := filepath.Join(f.movies, "The Matrix (1999) [tmdbid-603]", "The Matrix (1999).ru.srt")
|
||||
if links[0].Dst != wantMain || links[0].Kind != KindVideo {
|
||||
t.Errorf("main = %+v, want %q", links[0], wantMain)
|
||||
}
|
||||
if links[1].Dst != wantSub || links[1].Kind != KindSubtitle {
|
||||
t.Errorf("sub = %+v, want %q", links[1], wantSub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinks_Series(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
plan := Plan{
|
||||
Type: Series, Title: "Fargo", Year: 2015,
|
||||
Files: []PlanFile{
|
||||
{Src: f.srcFile(t, "Fargo/e1.mkv", "1"), Role: RoleEpisode, Season: intp(2), Episode: intp(1)},
|
||||
{Src: f.srcFile(t, "Fargo/e2.mkv", "2"), Role: RoleEpisode, Season: intp(2), Episode: intp(2)},
|
||||
},
|
||||
}
|
||||
links, err := f.l.BuildLinks(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLinks: %v", err)
|
||||
}
|
||||
want := filepath.Join(f.series, "Fargo (2015)", "Season 02", "Fargo (2015) S02E01.mkv")
|
||||
if links[0].Dst != want {
|
||||
t.Errorf("ep1 = %q, want %q", links[0].Dst, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinks_SeriesEpisodeWithoutNumber(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
plan := Plan{
|
||||
Type: Series, Title: "X", Year: 2020,
|
||||
Files: []PlanFile{{Src: f.srcFile(t, "x/e.mkv", "1"), Role: RoleEpisode, Season: intp(1)}},
|
||||
}
|
||||
if _, err := f.l.BuildLinks(plan); err == nil {
|
||||
t.Fatal("want error for episode without number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinks_EmptyPlanRejected(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
plan := Plan{Type: Movie, Title: "X", Year: 2020,
|
||||
Files: []PlanFile{{Src: "/x/sample.mkv", Role: "sample"}}}
|
||||
if _, err := f.l.BuildLinks(plan); err == nil {
|
||||
t.Fatal("want error when no linkable files")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLinks_TraversalTitleStaysInside(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
// Враждебное название не должно вывести за пределы библиотеки.
|
||||
plan := Plan{Type: Movie, Title: "../../etc/passwd", Year: 2020,
|
||||
Files: []PlanFile{{Src: f.srcFile(t, "m/f.mkv", "1"), Role: RoleMain}}}
|
||||
links, err := f.l.BuildLinks(plan)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLinks: %v", err)
|
||||
}
|
||||
if !underRoot(f.movies, links[0].Dst) {
|
||||
t.Errorf("dst escaped library: %q", links[0].Dst)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_CreatesHardlink(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
src := f.srcFile(t, "m/film.mkv", "data")
|
||||
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
|
||||
Files: []PlanFile{{Src: src, Role: RoleMain}}})
|
||||
|
||||
res, err := f.l.Apply(context.Background(), links)
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if len(res) != 1 || res[0].Status != StatusLinked {
|
||||
t.Fatalf("res = %+v", res)
|
||||
}
|
||||
// Тот же inode, источник цел.
|
||||
si, _ := os.Stat(src)
|
||||
di, _ := os.Stat(links[0].Dst)
|
||||
if !os.SameFile(si, di) {
|
||||
t.Error("dst is not a hardlink of src")
|
||||
}
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
t.Errorf("source must remain: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_Idempotent(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
src := f.srcFile(t, "m/film.mkv", "data")
|
||||
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
|
||||
Files: []PlanFile{{Src: src, Role: RoleMain}}})
|
||||
|
||||
if _, err := f.l.Apply(context.Background(), links); err != nil {
|
||||
t.Fatalf("first apply: %v", err)
|
||||
}
|
||||
res, err := f.l.Apply(context.Background(), links)
|
||||
if err != nil {
|
||||
t.Fatalf("second apply: %v", err)
|
||||
}
|
||||
if res[0].Status != StatusExists {
|
||||
t.Errorf("status = %q, want exists (idempotent)", res[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_CollisionNotOverwritten(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
src := f.srcFile(t, "m/film.mkv", "original")
|
||||
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
|
||||
Files: []PlanFile{{Src: src, Role: RoleMain}}})
|
||||
|
||||
// Занимаем цель посторонним файлом.
|
||||
if err := os.MkdirAll(filepath.Dir(links[0].Dst), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(links[0].Dst, []byte("foreign"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := f.l.Apply(context.Background(), links)
|
||||
if !errors.Is(err, ErrCollision) {
|
||||
t.Fatalf("err = %v, want ErrCollision", err)
|
||||
}
|
||||
// Посторонний файл не тронут.
|
||||
b, _ := os.ReadFile(links[0].Dst)
|
||||
if string(b) != "foreign" {
|
||||
t.Errorf("foreign file overwritten: %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndo_RemovesLinksAndPrunesDirs(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
links, _ := f.l.BuildLinks(Plan{Type: Series, Title: "Show", Year: 2021,
|
||||
Files: []PlanFile{
|
||||
{Src: f.srcFile(t, "s/e1.mkv", "1"), Role: RoleEpisode, Season: intp(1), Episode: intp(1)},
|
||||
}})
|
||||
if _, err := f.l.Apply(context.Background(), links); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := f.l.Undo(context.Background(), links)
|
||||
if err != nil {
|
||||
t.Fatalf("Undo: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("removed = %d, want 1", n)
|
||||
}
|
||||
if _, err := os.Stat(links[0].Dst); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("link still exists: %v", err)
|
||||
}
|
||||
// Пустые каталоги сезона и сериала подчищены, корень цел.
|
||||
if _, err := os.Stat(filepath.Join(f.series, "Show (2021)")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Errorf("show dir not pruned: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(f.series); err != nil {
|
||||
t.Errorf("series root must remain: %v", err)
|
||||
}
|
||||
// Источник цел.
|
||||
if _, err := os.Stat(links[0].Src); err != nil {
|
||||
t.Errorf("source removed by undo: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndo_Idempotent(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
links, _ := f.l.BuildLinks(Plan{Type: Movie, Title: "Film", Year: 2020,
|
||||
Files: []PlanFile{{Src: f.srcFile(t, "m/film.mkv", "1"), Role: RoleMain}}})
|
||||
if _, err := f.l.Apply(context.Background(), links); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.l.Undo(context.Background(), links); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Повторный undo — не ошибка (цель уже снята).
|
||||
n, err := f.l.Undo(context.Background(), links)
|
||||
if err != nil {
|
||||
t.Fatalf("second undo: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("removed = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUndo_RefusesOutsideLibrary(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
outside := filepath.Join(f.downloads, "victim.mkv")
|
||||
if _, err := f.l.Undo(context.Background(), []Link{{Dst: outside}}); err == nil {
|
||||
t.Fatal("undo must refuse paths outside libraries")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sanitizeComponent чистит один компонент пути (имя папки/файла): убирает
|
||||
// разделители, управляющие символы и неудобные для ФС/SMB знаки, схлопывает
|
||||
// пробелы и срезает точки/пробелы по краям. Кириллица и пробелы внутри
|
||||
// сохраняются. Результат гарантированно не содержит '/', '\\', ".." целиком
|
||||
// и не пуст (иначе ошибка у вызывающего).
|
||||
func sanitizeComponent(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r < 0x20 || r == 0x7f: // управляющие
|
||||
b.WriteByte(' ')
|
||||
case strings.ContainsRune(`/\:*?"<>|`, r): // разделители и недопустимые в SMB/NTFS
|
||||
b.WriteByte(' ')
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
out := strings.Join(strings.Fields(b.String()), " ") // схлопнуть пробелы
|
||||
out = strings.Trim(out, " .") // края: ни точек, ни пробелов
|
||||
return out
|
||||
}
|
||||
|
||||
// titleYear строит базу "Название (Год)" или "Название" при year == 0.
|
||||
func titleYear(title string, year int) (string, error) {
|
||||
t := sanitizeComponent(title)
|
||||
if t == "" {
|
||||
return "", fmt.Errorf("layout: пустое название после санитизации (%q)", title)
|
||||
}
|
||||
if year > 0 {
|
||||
return fmt.Sprintf("%s (%d)", t, year), nil
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// folderName добавляет provider-тег к базе: "Название (Год) [tmdbid-123]".
|
||||
func folderName(base, providerTag string) string {
|
||||
tag := sanitizeComponent(providerTag)
|
||||
if tag == "" {
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("%s [%s]", base, tag)
|
||||
}
|
||||
|
||||
// seasonFolder — "Season 00" (спецвыпуски) / "Season 01" / ...
|
||||
func seasonFolder(season int) string {
|
||||
return fmt.Sprintf("Season %02d", season)
|
||||
}
|
||||
|
||||
// episodeStem — "Название (Год) S01E02"; при двойной серии episodeEnd>episode
|
||||
// даёт "S01E02-E03".
|
||||
func episodeStem(base string, season, episode, episodeEnd int) string {
|
||||
if episodeEnd > episode {
|
||||
return fmt.Sprintf("%s S%02dE%02d-E%02d", base, season, episode, episodeEnd)
|
||||
}
|
||||
return fmt.Sprintf("%s S%02dE%02d", base, season, episode)
|
||||
}
|
||||
|
||||
// subtitleSuffix — ".ru", ".ru.forced" и т.п. (флаги после языка).
|
||||
func subtitleSuffix(lang string, flags []string) string {
|
||||
var b strings.Builder
|
||||
if l := sanitizeComponent(lang); l != "" {
|
||||
b.WriteByte('.')
|
||||
b.WriteString(strings.ToLower(l))
|
||||
}
|
||||
for _, f := range flags {
|
||||
if f = sanitizeComponent(f); f != "" {
|
||||
b.WriteByte('.')
|
||||
b.WriteString(strings.ToLower(f))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ext возвращает расширение файла источника в нижнем регистре (с точкой).
|
||||
func ext(src string) string {
|
||||
return strings.ToLower(filepath.Ext(src))
|
||||
}
|
||||
|
||||
// underRoot сообщает, лежит ли clean-путь p строго под каталогом root
|
||||
// (после filepath.Clean у обоих). Защита от traversal: даже если имя
|
||||
// прошло санитизацию, итог обязан остаться внутри библиотеки.
|
||||
func underRoot(root, p string) bool {
|
||||
root = filepath.Clean(root)
|
||||
p = filepath.Clean(p)
|
||||
if p == root {
|
||||
return false // сам корень — не цель для файла
|
||||
}
|
||||
return strings.HasPrefix(p, root+string(filepath.Separator))
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package layout
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSanitizeComponent(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"Дюна Часть вторая", "Дюна Часть вторая"},
|
||||
{"a/b\\c", "a b c"},
|
||||
{"..", ""},
|
||||
{"../../etc/passwd", "etc passwd"},
|
||||
{" trailing. ", "trailing"},
|
||||
{"name: sub*title?", "name sub title"},
|
||||
{"multi space", "multi space"},
|
||||
{"tab\tand\nnewline", "tab and newline"},
|
||||
{".hidden", "hidden"},
|
||||
{"a<b>c|d\"e", "a b c d e"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := sanitizeComponent(tt.in); got != tt.want {
|
||||
t.Errorf("sanitizeComponent(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitleYear(t *testing.T) {
|
||||
got, err := titleYear("The Matrix", 1999)
|
||||
if err != nil || got != "The Matrix (1999)" {
|
||||
t.Errorf("got %q, %v", got, err)
|
||||
}
|
||||
got, err = titleYear("No Year", 0)
|
||||
if err != nil || got != "No Year" {
|
||||
t.Errorf("got %q, %v", got, err)
|
||||
}
|
||||
if _, err := titleYear("///", 2000); err == nil {
|
||||
t.Error("want error for empty title after sanitize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderName(t *testing.T) {
|
||||
if got := folderName("Dune (2024)", "tmdbid-693134"); got != "Dune (2024) [tmdbid-693134]" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := folderName("Dune (2024)", ""); got != "Dune (2024)" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEpisodeStem(t *testing.T) {
|
||||
if got := episodeStem("Fargo (2015)", 2, 1, 0); got != "Fargo (2015) S02E01" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := episodeStem("Show", 1, 5, 6); got != "Show S01E05-E06" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeasonFolder(t *testing.T) {
|
||||
if got := seasonFolder(0); got != "Season 00" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := seasonFolder(12); got != "Season 12" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleSuffix(t *testing.T) {
|
||||
if got := subtitleSuffix("ru", nil); got != ".ru" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := subtitleSuffix("RU", []string{"forced"}); got != ".ru.forced" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := subtitleSuffix("", nil); got != "" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnderRoot(t *testing.T) {
|
||||
if !underRoot("/srv/media/movies", "/srv/media/movies/Film (2020)/Film (2020).mkv") {
|
||||
t.Error("want true for nested path")
|
||||
}
|
||||
if underRoot("/srv/media/movies", "/srv/media/movies") {
|
||||
t.Error("root itself is not a valid target")
|
||||
}
|
||||
if underRoot("/srv/media/movies", "/srv/media/series/x.mkv") {
|
||||
t.Error("sibling dir must be rejected")
|
||||
}
|
||||
if underRoot("/srv/media/movies", "/srv/media/movies/../series/x.mkv") {
|
||||
t.Error("traversal must be rejected after clean")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user