Раскладка файлов после распознавния
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user