- 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.
285 lines
7.2 KiB
Go
285 lines
7.2 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// ScanResult summarises one scan pass.
|
|
type ScanResult struct {
|
|
LibraryID int64 `json:"library_id"`
|
|
Scanned int `json:"scanned"`
|
|
Added int `json:"added"`
|
|
Updated int `json:"updated"`
|
|
Restored int `json:"restored"`
|
|
Missing int `json:"missing"`
|
|
Unchanged int `json:"unchanged"`
|
|
Duration int64 `json:"duration_ms"`
|
|
Errors []string `json:"errors,omitempty"`
|
|
}
|
|
|
|
// candidate is a supported file found on disk.
|
|
type candidate struct {
|
|
relPath string
|
|
size int64
|
|
mtime int64
|
|
inode int64
|
|
ext string
|
|
}
|
|
|
|
// knownFile is an existing row for this library.
|
|
type knownFile struct {
|
|
itemID int64
|
|
fileID int64
|
|
size int64
|
|
mtime int64
|
|
missing bool
|
|
}
|
|
|
|
// Scan walks a library's root and reconciles it with the database.
|
|
//
|
|
// Files that changed are re-upserted; files that vanished are marked missing
|
|
// rather than deleted, so their progress survives a temporarily unavailable
|
|
// mount. A scan never fails because of a single unreadable file — it records
|
|
// the error and continues, because a partial library is better than none.
|
|
func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanResult, error) {
|
|
start := time.Now()
|
|
res := ScanResult{LibraryID: lib.ID}
|
|
|
|
exts := extensionsFor(lib.Kind)
|
|
if len(exts) == 0 {
|
|
return res, fmt.Errorf("library %d: no extensions registered for kind %q", lib.ID, lib.Kind)
|
|
}
|
|
|
|
// 1. What do we already know?
|
|
known, err := loadKnownFiles(ctx, db, lib.ID)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
// 2. What is on disk?
|
|
found, errs := walkRoot(lib.RootPath, exts)
|
|
res.Errors = errs
|
|
|
|
// 3. Reconcile in one transaction.
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
for rel, c := range found {
|
|
res.Scanned++
|
|
prev, exists := known[rel]
|
|
|
|
switch {
|
|
case exists && !prev.missing && prev.size == c.size && prev.mtime == c.mtime:
|
|
res.Unchanged++
|
|
continue
|
|
|
|
case exists && prev.missing:
|
|
res.Restored++
|
|
case exists:
|
|
res.Updated++
|
|
default:
|
|
res.Added++
|
|
}
|
|
|
|
itemID, err := upsertItem(ctx, tx, lib, rel, c)
|
|
if err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", rel, err))
|
|
continue
|
|
}
|
|
if err := upsertFile(ctx, tx, itemID, rel, c); err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", rel, err))
|
|
continue
|
|
}
|
|
}
|
|
|
|
// 4. Anything we knew about that is gone.
|
|
for rel := range known {
|
|
if _, ok := found[rel]; ok {
|
|
continue
|
|
}
|
|
if err := markMissing(ctx, tx, lib.ID, rel, now); err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", rel, err))
|
|
continue
|
|
}
|
|
res.Missing++
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return res, fmt.Errorf("library: commit scan: %w", err)
|
|
}
|
|
|
|
res.Duration = time.Since(start).Milliseconds()
|
|
log.Info("scan complete",
|
|
"library", lib.Name, "kind", lib.Kind,
|
|
"scanned", res.Scanned, "added", res.Added, "updated", res.Updated,
|
|
"restored", res.Restored, "missing", res.Missing, "unchanged", res.Unchanged,
|
|
"duration_ms", res.Duration, "errors", len(res.Errors))
|
|
return res, nil
|
|
}
|
|
|
|
// walkRoot collects supported files under root, keyed by slash-separated
|
|
// path relative to root.
|
|
func walkRoot(root string, exts map[string]bool) (map[string]candidate, []string) {
|
|
out := make(map[string]candidate)
|
|
var errs []string
|
|
|
|
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", path, err))
|
|
// An unreadable directory should not abort the walk.
|
|
if d != nil && d.IsDir() {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
|
|
name := d.Name()
|
|
if d.IsDir() {
|
|
if path != root && strings.HasPrefix(name, ".") {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(name, ".") {
|
|
return nil
|
|
}
|
|
|
|
ext := strings.ToLower(filepath.Ext(name))
|
|
if !exts[ext] {
|
|
return nil
|
|
}
|
|
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", path, err))
|
|
return nil
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil
|
|
}
|
|
|
|
rel, err := filepath.Rel(root, path)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", path, err))
|
|
return nil
|
|
}
|
|
|
|
c := candidate{
|
|
relPath: filepath.ToSlash(rel),
|
|
size: info.Size(),
|
|
mtime: info.ModTime().UnixNano(),
|
|
ext: strings.TrimPrefix(ext, "."),
|
|
}
|
|
if st, ok := info.Sys().(*syscall.Stat_t); ok {
|
|
c.inode = int64(st.Ino)
|
|
}
|
|
out[c.relPath] = c
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", root, err))
|
|
}
|
|
return out, errs
|
|
}
|
|
|
|
func loadKnownFiles(ctx context.Context, db *sql.DB, libraryID int64) (map[string]knownFile, error) {
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT f.path, f.item_id, f.id, f.size, f.mtime, f.missing
|
|
FROM media_files f
|
|
JOIN media_items i ON i.id = f.item_id
|
|
WHERE i.library_id = ?`, libraryID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("library: load known files: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make(map[string]knownFile)
|
|
for rows.Next() {
|
|
var (
|
|
absPath string
|
|
kf knownFile
|
|
mtime string
|
|
missing int
|
|
)
|
|
if err := rows.Scan(&absPath, &kf.itemID, &kf.fileID, &kf.size, &mtime, &missing); err != nil {
|
|
return nil, err
|
|
}
|
|
kf.mtime = parseUnixNano(mtime)
|
|
kf.missing = missing == 1
|
|
out[absPath] = kf
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, rel string, c candidate) (int64, error) {
|
|
title := CleanTitle(rel)
|
|
var id int64
|
|
err := tx.QueryRowContext(ctx, `
|
|
INSERT INTO media_items (library_id, kind, path, title, sort_title, missing)
|
|
VALUES (?, ?, ?, ?, ?, 0)
|
|
ON CONFLICT (library_id, path)
|
|
DO UPDATE SET missing = 0
|
|
RETURNING id`,
|
|
lib.ID, lib.Kind, rel, title, strings.ToLower(title),
|
|
).Scan(&id)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("upsert item: %w", err)
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, rel string, c candidate) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO media_files (item_id, path, size, mtime, inode, container, missing)
|
|
VALUES (?, ?, ?, ?, ?, ?, 0)
|
|
ON CONFLICT (path)
|
|
DO UPDATE SET item_id = excluded.item_id, size = excluded.size,
|
|
mtime = excluded.mtime, inode = excluded.inode,
|
|
container = excluded.container, missing = 0`,
|
|
itemID, rel, c.size, fmt.Sprint(c.mtime), c.inode, c.ext)
|
|
if err != nil {
|
|
return fmt.Errorf("upsert file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func markMissing(ctx context.Context, tx *sql.Tx, libraryID int64, rel, now string) error {
|
|
if _, err := tx.ExecContext(ctx,
|
|
`UPDATE media_files SET missing = 1 WHERE path = ?`, rel); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.ExecContext(ctx, `
|
|
UPDATE media_items SET missing = 1
|
|
WHERE library_id = ? AND path = ?`, libraryID, rel)
|
|
return err
|
|
}
|
|
|
|
func parseUnixNano(s string) int64 {
|
|
var n int64
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] < '0' || s[i] > '9' {
|
|
return 0
|
|
}
|
|
n = n*10 + int64(s[i]-'0')
|
|
}
|
|
return n
|
|
}
|
|
|
|
// RootExists reports whether a library's root is currently reachable.
|
|
func RootExists(lib Library) bool {
|
|
info, err := os.Stat(lib.RootPath)
|
|
return err == nil && info.IsDir()
|
|
}
|