Add the initial mediamark app
Single Go binary serving the API and an embedded keyboard-first UI for promoting fafflix titles into the cheeztv kids tree via hardlinks. - internal/library: hardlink sync, idempotent re-runs, drift reporting, strict single-path-element name validation as the traversal guard - internal/arr: minimal sonarr/radarr v3 client with a 60s list cache and a key-brokered poster proxy - internal/auth: server-side Authentik group enforcement on every route - internal/server: library JSON API, art proxy, health probes, SPA - ui: two-tile landing page, fuzzy-filtered title list, detail panel - Makefile, Dockerfile, .woodpecker pipelines, pre-commit config
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
// Package library models the two-tree media layout and the hardlink sync that
|
||||
// marks a title as kids ("cheeztv") content.
|
||||
//
|
||||
// Layout under the media root:
|
||||
//
|
||||
// fafflix/{tvseries,movies}/<title>/... source library
|
||||
// cheeztv/{tvseries,movies}/<title>/... kids tree (hardlinks into fafflix)
|
||||
//
|
||||
// Marking a title hardlinks every regular file across; unmarking removes only
|
||||
// the kids-side directory, which never destroys data because the source inode
|
||||
// keeps a link.
|
||||
package library
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tree names, relative to the media root.
|
||||
const (
|
||||
SourceTree = "fafflix"
|
||||
KidsTree = "cheeztv"
|
||||
)
|
||||
|
||||
// Section is a library section directory shared by both trees.
|
||||
type Section string
|
||||
|
||||
// The supported sections.
|
||||
const (
|
||||
SectionTVSeries Section = "tvseries"
|
||||
SectionMovies Section = "movies"
|
||||
)
|
||||
|
||||
// Sections lists every valid section in display order.
|
||||
var Sections = []Section{SectionMovies, SectionTVSeries}
|
||||
|
||||
// ErrInvalidSection is returned for a section outside Sections.
|
||||
var ErrInvalidSection = errors.New("invalid section")
|
||||
|
||||
// ErrInvalidName is returned when a title name is not a single, clean path
|
||||
// element — the guard against path traversal out of the library roots.
|
||||
var ErrInvalidName = errors.New("invalid title name")
|
||||
|
||||
// ErrNotFound is returned when a title has no source directory.
|
||||
var ErrNotFound = errors.New("title not found")
|
||||
|
||||
// ParseSection validates s against the known sections.
|
||||
func ParseSection(s string) (Section, error) {
|
||||
for _, k := range Sections {
|
||||
if string(k) == s {
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%w: %q", ErrInvalidSection, s)
|
||||
}
|
||||
|
||||
// ValidateName rejects anything that is not a single, clean path element:
|
||||
// empties, dot entries, absolute paths, and any name containing a separator.
|
||||
// This is the sole traversal guard between an HTTP path segment and the
|
||||
// filesystem, so it is deliberately strict rather than sanitising.
|
||||
func ValidateName(name string) error {
|
||||
switch {
|
||||
case name == "":
|
||||
return fmt.Errorf("%w: empty", ErrInvalidName)
|
||||
case name == "." || name == "..":
|
||||
return fmt.Errorf("%w: dot entry", ErrInvalidName)
|
||||
// A backslash is a legal byte in a Linux filename, but it is a separator on
|
||||
// the platforms that write into these libraries; reject it rather than
|
||||
// reason about which side produced the name.
|
||||
case strings.ContainsRune(name, '/'), strings.ContainsRune(name, '\\'), strings.ContainsRune(name, os.PathSeparator):
|
||||
return fmt.Errorf("%w: contains a path separator", ErrInvalidName)
|
||||
case strings.ContainsRune(name, 0):
|
||||
return fmt.Errorf("%w: contains NUL", ErrInvalidName)
|
||||
case filepath.IsAbs(name):
|
||||
return fmt.Errorf("%w: absolute path", ErrInvalidName)
|
||||
case name != filepath.Clean(name):
|
||||
return fmt.Errorf("%w: not a clean path element", ErrInvalidName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Title is the reported state of one library title.
|
||||
type Title struct {
|
||||
Name string `json:"name"`
|
||||
// Marked is true when the kids tree has a directory of the same name.
|
||||
Marked bool `json:"marked"`
|
||||
// NeedsSync is true for a marked title whose source holds regular files
|
||||
// that are not linked into the kids tree (new episodes, upgrades).
|
||||
NeedsSync bool `json:"needsSync"`
|
||||
// SizeBytes and FileCount describe the source directory.
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
FileCount int `json:"fileCount"`
|
||||
// UnlinkedFiles counts the source files missing from the kids tree; zero
|
||||
// for an unmarked title.
|
||||
UnlinkedFiles int `json:"unlinkedFiles"`
|
||||
}
|
||||
|
||||
// Library reads and mutates the two trees under Root.
|
||||
type Library struct {
|
||||
Root string
|
||||
}
|
||||
|
||||
// New returns a Library rooted at the (cleaned) media root.
|
||||
func New(root string) *Library { return &Library{Root: filepath.Clean(root)} }
|
||||
|
||||
// sourceRoot and kidsRoot are the per-section tree roots.
|
||||
func (l *Library) sourceRoot(s Section) string { return filepath.Join(l.Root, SourceTree, string(s)) }
|
||||
func (l *Library) kidsRoot(s Section) string { return filepath.Join(l.Root, KidsTree, string(s)) }
|
||||
|
||||
// paths resolves the source and kids directories for a validated title, and
|
||||
// re-verifies containment: ValidateName already makes escape impossible, but
|
||||
// the check costs nothing and keeps the invariant local to the filesystem call.
|
||||
func (l *Library) paths(section Section, name string) (src, kids string, err error) {
|
||||
if _, err := ParseSection(string(section)); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err := ValidateName(name); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
src = filepath.Join(l.sourceRoot(section), name)
|
||||
kids = filepath.Join(l.kidsRoot(section), name)
|
||||
if !under(l.sourceRoot(section), src) || !under(l.kidsRoot(section), kids) {
|
||||
return "", "", fmt.Errorf("%w: escapes the library root", ErrInvalidName)
|
||||
}
|
||||
return src, kids, nil
|
||||
}
|
||||
|
||||
// under reports whether path is root itself or lies beneath it.
|
||||
func under(root, path string) bool {
|
||||
rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// Ready reports whether the media root is visible, backing /readyz.
|
||||
func (l *Library) Ready() error {
|
||||
fi, err := os.Stat(l.Root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return fmt.Errorf("media root %q is not a directory", l.Root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List reports every title in a section, sorted case-insensitively by name.
|
||||
func (l *Library) List(section Section) ([]Title, error) {
|
||||
if _, err := ParseSection(string(section)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(l.sourceRoot(section))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []Title{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Title, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || ValidateName(e.Name()) != nil {
|
||||
continue
|
||||
}
|
||||
t, err := l.Stat(section, e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
li, lj := strings.ToLower(out[i].Name), strings.ToLower(out[j].Name)
|
||||
if li == lj {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return li < lj
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Stat reports one title's size, file count, marked state and link drift.
|
||||
func (l *Library) Stat(section Section, name string) (Title, error) {
|
||||
src, kids, err := l.paths(section, name)
|
||||
if err != nil {
|
||||
return Title{}, err
|
||||
}
|
||||
fi, err := os.Stat(src)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return Title{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name)
|
||||
}
|
||||
return Title{}, err
|
||||
}
|
||||
if !fi.IsDir() {
|
||||
return Title{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name)
|
||||
}
|
||||
|
||||
t := Title{Name: name}
|
||||
if kfi, err := os.Stat(kids); err == nil && kfi.IsDir() {
|
||||
t.Marked = true
|
||||
}
|
||||
|
||||
err = filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
// A vanished file mid-walk is normal on a live library; skip it
|
||||
// rather than failing the whole listing.
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
t.FileCount++
|
||||
t.SizeBytes += info.Size()
|
||||
if !t.Marked {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !sameFile(p, filepath.Join(kids, rel)) {
|
||||
t.UnlinkedFiles++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Title{}, err
|
||||
}
|
||||
t.NeedsSync = t.Marked && t.UnlinkedFiles > 0
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// sameFile reports whether both paths resolve to the same inode.
|
||||
func sameFile(a, b string) bool {
|
||||
ai, err := os.Lstat(a)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
bi, err := os.Lstat(b)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return os.SameFile(ai, bi)
|
||||
}
|
||||
|
||||
// SyncResult reports what a Mark run changed.
|
||||
type SyncResult struct {
|
||||
Linked int `json:"linked"`
|
||||
Replaced int `json:"replaced"`
|
||||
Unchanged int `json:"unchanged"`
|
||||
Skipped int `json:"skipped"`
|
||||
Dirs int `json:"dirs"`
|
||||
}
|
||||
|
||||
// Mark hardlinks every regular file of a title into the kids tree, creating the
|
||||
// directory skeleton as it goes. It is idempotent: files already linked to the
|
||||
// same inode are left alone, a kids-side file pointing at a different inode is
|
||||
// replaced, and symlinks/devices/sockets are skipped. Re-running after new
|
||||
// episodes arrive syncs only the new files.
|
||||
func (l *Library) Mark(section Section, name string) (SyncResult, error) {
|
||||
src, kids, err := l.paths(section, name)
|
||||
if err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
fi, err := os.Stat(src)
|
||||
if err != nil || !fi.IsDir() {
|
||||
return SyncResult{}, fmt.Errorf("%w: %s/%s", ErrNotFound, section, name)
|
||||
}
|
||||
if err := os.MkdirAll(kids, 0o755); err != nil {
|
||||
return SyncResult{}, err
|
||||
}
|
||||
|
||||
var res SyncResult
|
||||
walkErr := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == "." {
|
||||
return nil
|
||||
}
|
||||
dst := filepath.Join(kids, rel)
|
||||
if !under(kids, dst) {
|
||||
// Unreachable for a well-formed walk; belt-and-braces against a
|
||||
// crafted name surviving the entry guards.
|
||||
return fmt.Errorf("refusing to write outside the kids tree: %s", dst)
|
||||
}
|
||||
switch {
|
||||
case d.IsDir():
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
res.Dirs++
|
||||
return nil
|
||||
case !d.Type().IsRegular():
|
||||
res.Skipped++
|
||||
return nil
|
||||
}
|
||||
return linkFile(p, dst, &res)
|
||||
})
|
||||
if walkErr != nil {
|
||||
return res, walkErr
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// linkFile hardlinks src to dst, replacing a stale link to a different inode.
|
||||
func linkFile(src, dst string, res *SyncResult) error {
|
||||
di, err := os.Lstat(dst)
|
||||
switch {
|
||||
case err == nil:
|
||||
si, serr := os.Lstat(src)
|
||||
if serr == nil && di.Mode().IsRegular() && os.SameFile(si, di) {
|
||||
res.Unchanged++
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(dst); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Link(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
res.Replaced++
|
||||
return nil
|
||||
case !os.IsNotExist(err):
|
||||
return err
|
||||
}
|
||||
if err := os.Link(src, dst); err != nil {
|
||||
// A file that disappeared between the walk and the link is not fatal.
|
||||
if os.IsNotExist(err) {
|
||||
res.Skipped++
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
res.Linked++
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unmark removes a title's kids-tree directory. Only hardlinks live there, so
|
||||
// the source library is untouched. Removing an already-absent title succeeds.
|
||||
func (l *Library) Unmark(section Section, name string) error {
|
||||
_, kids, err := l.paths(section, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(kids)
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newLib builds a media root with the four tree/section dirs and returns it.
|
||||
func newLib(t *testing.T) *Library {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, tree := range []string{SourceTree, KidsTree} {
|
||||
for _, s := range Sections {
|
||||
if err := os.MkdirAll(filepath.Join(root, tree, string(s)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return New(root)
|
||||
}
|
||||
|
||||
// writeSrc creates a source file with content, making parents as needed.
|
||||
func writeSrc(t *testing.T, l *Library, s Section, rel, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(l.sourceRoot(s), 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 TestValidateNameRejectsTraversal(t *testing.T) {
|
||||
bad := []string{
|
||||
"", ".", "..", "../etc", "..", "a/b", "/abs", "/etc/passwd",
|
||||
"sub/../..", "./x", "x/", "a\x00b", "..\\evil",
|
||||
}
|
||||
for _, name := range bad {
|
||||
if err := ValidateName(name); err == nil {
|
||||
t.Errorf("ValidateName(%q) = nil, want error", name)
|
||||
} else if !errors.Is(err, ErrInvalidName) {
|
||||
t.Errorf("ValidateName(%q) error %v, want ErrInvalidName", name, err)
|
||||
}
|
||||
}
|
||||
good := []string{"The Muppets", "Movie (2019)", "a.b.c", "...", "dot.dir", "Ünïcødé"}
|
||||
for _, name := range good {
|
||||
if err := ValidateName(name); err != nil {
|
||||
t.Errorf("ValidateName(%q) = %v, want nil", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathsRejectsTraversalAndKeepsContainment(t *testing.T) {
|
||||
l := newLib(t)
|
||||
if _, _, err := l.paths(SectionMovies, "../../etc"); err == nil {
|
||||
t.Fatal("paths accepted a traversal name")
|
||||
}
|
||||
src, kids, err := l.paths(SectionMovies, "Nemo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !under(l.sourceRoot(SectionMovies), src) || !under(l.kidsRoot(SectionMovies), kids) {
|
||||
t.Fatalf("resolved paths escaped roots: %s %s", src, kids)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkOperationsCannotEscapeRoot proves a crafted name never touches a file
|
||||
// outside the media root, even when the target already exists there.
|
||||
func TestMarkOperationsCannotEscapeRoot(t *testing.T) {
|
||||
l := newLib(t)
|
||||
outside := filepath.Join(t.TempDir(), "victim")
|
||||
if err := os.MkdirAll(outside, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"../../../" + filepath.Base(outside), "..", "../movies"} {
|
||||
if _, err := l.Mark(SectionMovies, name); !errors.Is(err, ErrInvalidName) {
|
||||
t.Errorf("Mark(%q) error = %v, want ErrInvalidName", name, err)
|
||||
}
|
||||
if err := l.Unmark(SectionMovies, name); !errors.Is(err, ErrInvalidName) {
|
||||
t.Errorf("Unmark(%q) error = %v, want ErrInvalidName", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(outside); err != nil {
|
||||
t.Fatalf("victim directory was disturbed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkHardlinksTreeAndIsIdempotent(t *testing.T) {
|
||||
l := newLib(t)
|
||||
writeSrc(t, l, SectionTVSeries, "Bluey/Season 01/e01.mkv", "one")
|
||||
writeSrc(t, l, SectionTVSeries, "Bluey/Season 01/e02.mkv", "two")
|
||||
writeSrc(t, l, SectionTVSeries, "Bluey/poster.jpg", "art")
|
||||
|
||||
res, err := l.Mark(SectionTVSeries, "Bluey")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Linked != 3 {
|
||||
t.Fatalf("Linked = %d, want 3", res.Linked)
|
||||
}
|
||||
|
||||
// Every kids file must share an inode with its source.
|
||||
srcFile := filepath.Join(l.sourceRoot(SectionTVSeries), "Bluey/Season 01/e01.mkv")
|
||||
kidsFile := filepath.Join(l.kidsRoot(SectionTVSeries), "Bluey/Season 01/e01.mkv")
|
||||
if !sameFile(srcFile, kidsFile) {
|
||||
t.Fatal("kids file is not a hardlink of the source")
|
||||
}
|
||||
|
||||
// Re-running links nothing new.
|
||||
res2, err := l.Mark(SectionTVSeries, "Bluey")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res2.Linked != 0 || res2.Unchanged != 3 {
|
||||
t.Fatalf("second Mark = %+v, want 0 linked / 3 unchanged", res2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkSyncsNewFilesAndReplacesStaleLinks(t *testing.T) {
|
||||
l := newLib(t)
|
||||
writeSrc(t, l, SectionTVSeries, "Bluey/e01.mkv", "one")
|
||||
if _, err := l.Mark(SectionTVSeries, "Bluey"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// A new episode arrives and an existing file is replaced upstream (a new
|
||||
// inode with the same name, as an *arr upgrade does).
|
||||
writeSrc(t, l, SectionTVSeries, "Bluey/e02.mkv", "two")
|
||||
if err := os.Remove(filepath.Join(l.sourceRoot(SectionTVSeries), "Bluey/e01.mkv")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeSrc(t, l, SectionTVSeries, "Bluey/e01.mkv", "one-upgraded")
|
||||
|
||||
tit, err := l.Stat(SectionTVSeries, "Bluey")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tit.NeedsSync || tit.UnlinkedFiles != 2 {
|
||||
t.Fatalf("drift = %+v, want NeedsSync with 2 unlinked", tit)
|
||||
}
|
||||
|
||||
res, err := l.Mark(SectionTVSeries, "Bluey")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Linked != 1 || res.Replaced != 1 {
|
||||
t.Fatalf("sync = %+v, want 1 linked / 1 replaced", res)
|
||||
}
|
||||
tit, err = l.Stat(SectionTVSeries, "Bluey")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tit.NeedsSync || tit.UnlinkedFiles != 0 {
|
||||
t.Fatalf("post-sync drift = %+v, want none", tit)
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(l.kidsRoot(SectionTVSeries), "Bluey/e01.mkv"))
|
||||
if err != nil || string(b) != "one-upgraded" {
|
||||
t.Fatalf("stale link not replaced: %q %v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkSkipsSymlinks(t *testing.T) {
|
||||
l := newLib(t)
|
||||
writeSrc(t, l, SectionMovies, "Nemo/movie.mkv", "film")
|
||||
link := filepath.Join(l.sourceRoot(SectionMovies), "Nemo", "elsewhere.mkv")
|
||||
if err := os.Symlink("/dev/null", link); err != nil {
|
||||
t.Skipf("symlinks unsupported: %v", err)
|
||||
}
|
||||
res, err := l.Mark(SectionMovies, "Nemo")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Linked != 1 || res.Skipped != 1 {
|
||||
t.Fatalf("res = %+v, want 1 linked / 1 skipped", res)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(l.kidsRoot(SectionMovies), "Nemo", "elsewhere.mkv")); !os.IsNotExist(err) {
|
||||
t.Fatal("symlink was copied into the kids tree")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarkLeavesSourceIntact(t *testing.T) {
|
||||
l := newLib(t)
|
||||
writeSrc(t, l, SectionMovies, "Nemo/movie.mkv", "film")
|
||||
if _, err := l.Mark(SectionMovies, "Nemo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := l.Unmark(SectionMovies, "Nemo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(l.kidsRoot(SectionMovies), "Nemo")); !os.IsNotExist(err) {
|
||||
t.Fatal("kids directory survived unmark")
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(l.sourceRoot(SectionMovies), "Nemo/movie.mkv"))
|
||||
if err != nil || string(b) != "film" {
|
||||
t.Fatalf("source damaged by unmark: %q %v", b, err)
|
||||
}
|
||||
// Unmarking again is a no-op, not an error.
|
||||
if err := l.Unmark(SectionMovies, "Nemo"); err != nil {
|
||||
t.Fatalf("second Unmark = %v, want nil", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatAndListReportSizeCountAndMarks(t *testing.T) {
|
||||
l := newLib(t)
|
||||
writeSrc(t, l, SectionMovies, "Zootopia/movie.mkv", "0123456789")
|
||||
writeSrc(t, l, SectionMovies, "Zootopia/extra.nfo", "abc")
|
||||
writeSrc(t, l, SectionMovies, "aladdin/movie.mkv", "xy")
|
||||
if _, err := l.Mark(SectionMovies, "Zootopia"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := l.Stat(SectionMovies, "Zootopia")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.FileCount != 2 || got.SizeBytes != 13 || !got.Marked || got.NeedsSync {
|
||||
t.Fatalf("Stat = %+v, want 2 files / 13 bytes / marked / synced", got)
|
||||
}
|
||||
|
||||
list, err := l.List(SectionMovies)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("List returned %d titles, want 2", len(list))
|
||||
}
|
||||
// Case-insensitive ordering: "aladdin" before "Zootopia".
|
||||
if list[0].Name != "aladdin" || list[1].Name != "Zootopia" {
|
||||
t.Fatalf("List order = %q, %q", list[0].Name, list[1].Name)
|
||||
}
|
||||
if list[0].Marked || !list[1].Marked {
|
||||
t.Fatalf("marked flags wrong: %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMissingSectionIsEmptyNotError(t *testing.T) {
|
||||
l := New(t.TempDir())
|
||||
got, err := l.List(SectionMovies)
|
||||
if err != nil {
|
||||
t.Fatalf("List = %v, want nil error", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("List = %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatMissingTitleIsNotFound(t *testing.T) {
|
||||
l := newLib(t)
|
||||
if _, err := l.Stat(SectionMovies, "Ghost"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Stat = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := l.Mark(SectionMovies, "Ghost"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("Mark = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSection(t *testing.T) {
|
||||
for _, ok := range []string{"movies", "tvseries"} {
|
||||
if _, err := ParseSection(ok); err != nil {
|
||||
t.Errorf("ParseSection(%q) = %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "Movies", "music", "../movies", "tvseries/x"} {
|
||||
if _, err := ParseSection(bad); !errors.Is(err, ErrInvalidSection) {
|
||||
t.Errorf("ParseSection(%q) = %v, want ErrInvalidSection", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReady(t *testing.T) {
|
||||
l := newLib(t)
|
||||
if err := l.Ready(); err != nil {
|
||||
t.Fatalf("Ready = %v", err)
|
||||
}
|
||||
if err := New(filepath.Join(t.TempDir(), "absent")).Ready(); err == nil {
|
||||
t.Fatal("Ready on a missing root = nil, want error")
|
||||
}
|
||||
f := filepath.Join(t.TempDir(), "file")
|
||||
if err := os.WriteFile(f, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := New(f).Ready(); err == nil {
|
||||
t.Fatal("Ready on a file root = nil, want error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user