// 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}/
/... source library
// cheeztv/{tvseries,movies}//... 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)
}