Vellum/internal/library/library.go
mithral 5458b0a28a Phase 2: libraries and the file scanner
- library: CRUD with root-path resolution (absolute, symlink-free, must be
  readable). A library name is unique; the root is validated at creation.
- registry: media types declare their extensions; the core never imports a
  module. RegisterType is the extension point.
- scanner: walks a root, reconciles media_items/media_files in one
  transaction, skips unchanged files by size+mtime, and marks vanished files
  missing rather than deleting them (progress is the one thing here that
  cannot be regenerated). A single unreadable file never fails a scan.
- migration 0002: `missing` flags on items and files, plus indexes.
- endpoints: media-types, library list/get/create/delete, per-library items,
  and scan. Library reads go through the same grant chokepoint as everything
  else; an unauthorised library 404s rather than 403s so its existence is not
  leaked.
2026-10-09 16:33:56 -04:00

192 lines
5.5 KiB
Go

package library
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
var (
// ErrNotFound means no library matched.
ErrNotFound = errors.New("library: not found")
// ErrNameExists means the library name is taken.
ErrNameExists = errors.New("library: name already in use")
// ErrInvalidRoot means the root path is missing, not a directory, or not
// readable.
ErrInvalidRoot = errors.New("library: invalid root path")
)
// Library is a media root of a single kind.
type Library struct {
ID int64 `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
RootPath string `json:"root_path"`
CreatedAt time.Time `json:"created_at"`
}
const librarySelect = `SELECT id, name, kind, root_path, created_at FROM libraries`
// Create validates and inserts a library.
//
// The root path is resolved to an absolute, symlink-free path and must exist.
// Storing the resolved form matters: the scanner compares filesystem paths
// against this value, and "/media/../media" would silently match nothing.
func Create(ctx context.Context, db *sql.DB, name, kind, rootPath string) (Library, error) {
name = strings.TrimSpace(name)
if name == "" {
return Library{}, errors.New("library: name must not be empty")
}
if err := ValidateKind(kind); err != nil {
return Library{}, err
}
resolved, err := resolveRoot(rootPath)
if err != nil {
return Library{}, err
}
var lib Library
var created string
err = db.QueryRowContext(ctx, `
INSERT INTO libraries (name, kind, root_path)
VALUES (?, ?, ?)
RETURNING id, created_at`,
name, strings.ToLower(kind), resolved,
).Scan(&lib.ID, &created)
if err != nil {
if isUnique(err) {
return Library{}, ErrNameExists
}
return Library{}, fmt.Errorf("library: create: %w", err)
}
lib.Name, lib.Kind, lib.RootPath = name, strings.ToLower(kind), resolved
lib.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
return lib, nil
}
// Get loads one library by id.
func Get(ctx context.Context, db *sql.DB, id int64) (Library, error) {
return scanLibrary(db.QueryRowContext(ctx, librarySelect+` WHERE id = ?`, id))
}
// List returns all libraries.
func List(ctx context.Context, db *sql.DB) ([]Library, error) {
return queryLibraries(ctx, db, librarySelect+` ORDER BY name`)
}
// ListVisible returns the libraries in the given id set. An empty set yields an
// empty slice, never "all" — that distinction is the whole point of the
// access-control model.
func ListVisible(ctx context.Context, db *sql.DB, ids []int64) ([]Library, error) {
if len(ids) == 0 {
return []Library{}, nil
}
placeholders := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
q := librarySelect + ` WHERE id IN (` + strings.Join(placeholders, ",") + `) ORDER BY name`
return queryLibraries(ctx, db, q, args...)
}
// Delete removes a library. Items, files, and grants cascade.
func Delete(ctx context.Context, db *sql.DB, id int64) error {
res, err := db.ExecContext(ctx, `DELETE FROM libraries WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("library: delete: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// CountItems returns how many items a library holds (excluding missing).
func CountItems(ctx context.Context, db *sql.DB, id int64) (int, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT count(*) FROM media_items WHERE library_id = ? AND missing = 0`, id).Scan(&n)
return n, err
}
// ── helpers ────────────────────────────────────────────────────────────
func resolveRoot(rootPath string) (string, error) {
if strings.TrimSpace(rootPath) == "" {
return "", fmt.Errorf("%w: path is empty", ErrInvalidRoot)
}
abs, err := filepath.Abs(rootPath)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err)
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
abs = resolved
} else {
return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err)
}
info, err := os.Stat(abs)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err)
}
if !info.IsDir() {
return "", fmt.Errorf("%w: %s is not a directory", ErrInvalidRoot, abs)
}
// Confirm readability; a directory can exist and still deny access.
f, err := os.Open(abs)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrInvalidRoot, err)
}
_ = f.Close()
return abs, nil
}
func queryLibraries(ctx context.Context, db *sql.DB, q string, args ...any) ([]Library, error) {
rows, err := db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("library: query: %w", err)
}
defer rows.Close()
out := []Library{}
for rows.Next() {
lib, err := scanLibrary(rows)
if err != nil {
return nil, err
}
out = append(out, lib)
}
return out, rows.Err()
}
type scanner interface{ Scan(dest ...any) error }
func scanLibrary(row scanner) (Library, error) {
var lib Library
var created string
if err := row.Scan(&lib.ID, &lib.Name, &lib.Kind, &lib.RootPath, &created); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return Library{}, ErrNotFound
}
return Library{}, err
}
lib.CreatedAt, _ = time.Parse(time.RFC3339Nano, created)
return lib, nil
}
func isUnique(err error) bool {
if err == nil {
return false
}
msg := strings.ToUpper(err.Error())
return strings.Contains(msg, "UNIQUE CONSTRAINT FAILED") || strings.Contains(msg, "CONSTRAINT FAILED: UNIQUE")
}