- 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.
192 lines
5.5 KiB
Go
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")
|
|
}
|