A book is a folder of chapters, not a file. The scanner gained a grouping
mode so a directory of tracks becomes one item with ordered files.
- registry: MediaType.Group (file | directory). Audiobooks and music group by
directory; everything else stays one item per file.
- scanner: builds groups, upserts one item per group, marks items missing when
their directory disappears, and keeps the file-level added/updated/missing
accounting. Tracks are ordered naturally, so chapter 2 precedes chapter 10.
- natsort: one shared natural-order comparator, now used by both the scanner
and the comics module (which had its own copy).
- media: GET /items/{id}/files/{fileID} streams one specific track. It
verifies the file belongs to the item, so an item id cannot be paired with
another item's file id.
No new module was needed for playback: the polymorphic progress table already
keys on file_id, so per-chapter resume fell out of the existing design. The
web player resumes the exact track and offset.
Verified: 3 chapters grouped into one book titled "Mistborn", natural order,
range request returned 206, mismatched file id 404s, per-track progress
round-trips.
392 lines
9.7 KiB
Go
392 lines
9.7 KiB
Go
package library
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/mithral/vellum/internal/natsort"
|
|
)
|
|
|
|
// 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 media_files row for this library.
|
|
type knownFile struct {
|
|
itemID int64
|
|
fileID int64
|
|
size int64
|
|
mtime int64
|
|
missing bool
|
|
}
|
|
|
|
// group is one media item: either a single file or a directory of tracks.
|
|
type group struct {
|
|
key string // media_items.path (unique within the library)
|
|
title string
|
|
files []candidate
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
knownFiles, err := loadKnownFiles(ctx, db, lib.ID)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
knownItems, err := loadKnownItems(ctx, db, lib.ID)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
|
|
found, errs := walkRoot(lib.RootPath, exts)
|
|
res.Errors = errs
|
|
|
|
groups := buildGroups(lib, found)
|
|
|
|
tx, err := db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
seenFiles := make(map[string]bool, len(found))
|
|
|
|
for _, key := range sortedGroupKeys(groups) {
|
|
g := groups[key]
|
|
|
|
itemID, err := upsertItem(ctx, tx, lib, g.key, g.title)
|
|
if err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", g.key, err))
|
|
continue
|
|
}
|
|
|
|
for _, c := range g.files {
|
|
res.Scanned++
|
|
seenFiles[c.relPath] = true
|
|
|
|
prev, exists := knownFiles[c.relPath]
|
|
if exists && !prev.missing && prev.size == c.size && prev.mtime == c.mtime {
|
|
res.Unchanged++
|
|
continue
|
|
}
|
|
|
|
if err := upsertFile(ctx, tx, itemID, c); err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", c.relPath, err))
|
|
continue
|
|
}
|
|
switch {
|
|
case !exists:
|
|
res.Added++
|
|
case prev.missing:
|
|
res.Restored++
|
|
default:
|
|
res.Updated++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Items whose group is gone entirely.
|
|
for key := range knownItems {
|
|
if _, ok := groups[key]; ok {
|
|
continue
|
|
}
|
|
if err := markItemMissing(ctx, tx, lib.ID, key, now); err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", key, err))
|
|
continue
|
|
}
|
|
res.Missing++
|
|
}
|
|
|
|
// Individual files that vanished but whose item remains.
|
|
for rel := range knownFiles {
|
|
if seenFiles[rel] {
|
|
continue
|
|
}
|
|
if err := markFileMissing(ctx, tx, rel); err != nil {
|
|
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", rel, err))
|
|
}
|
|
}
|
|
|
|
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, "grouping", GroupModeFor(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
|
|
}
|
|
|
|
// buildGroups turns found files into items. Directory-grouped kinds (audiobooks,
|
|
// music) collapse a folder of tracks into one item; everything else is one item
|
|
// per file.
|
|
func buildGroups(lib Library, found map[string]candidate) map[string]group {
|
|
mode := GroupModeFor(lib.Kind)
|
|
groups := make(map[string]group)
|
|
|
|
for _, rel := range sortedCandidateKeys(found) {
|
|
c := found[rel]
|
|
|
|
var key, title string
|
|
if mode == GroupByDirectory {
|
|
dir := path.Dir(rel)
|
|
if dir == "." {
|
|
key, title = ".", lib.Name
|
|
} else {
|
|
key, title = dir, CleanTitle(path.Base(dir))
|
|
}
|
|
} else {
|
|
key, title = rel, CleanTitle(rel)
|
|
}
|
|
|
|
g := groups[key]
|
|
g.key, g.title = key, title
|
|
g.files = append(g.files, c)
|
|
groups[key] = g
|
|
}
|
|
|
|
// Natural order so track 2 precedes track 10.
|
|
for k, g := range groups {
|
|
sort.Slice(g.files, func(i, j int) bool {
|
|
return natsort.Less(g.files[i].relPath, g.files[j].relPath)
|
|
})
|
|
groups[k] = g
|
|
}
|
|
return groups
|
|
}
|
|
|
|
// 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(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", p, err))
|
|
if d != nil && d.IsDir() {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
|
|
name := d.Name()
|
|
if d.IsDir() {
|
|
if p != 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", p, err))
|
|
return nil
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil
|
|
}
|
|
|
|
rel, err := filepath.Rel(root, p)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Sprintf("%s: %v", p, 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 loadKnownItems(ctx context.Context, db *sql.DB, libraryID int64) (map[string]int64, error) {
|
|
rows, err := db.QueryContext(ctx,
|
|
`SELECT path, id FROM media_items WHERE library_id = ?`, libraryID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("library: load known items: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make(map[string]int64)
|
|
for rows.Next() {
|
|
var p string
|
|
var id int64
|
|
if err := rows.Scan(&p, &id); err != nil {
|
|
return nil, err
|
|
}
|
|
out[p] = id
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, key, title string) (int64, error) {
|
|
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, key, 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, 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, c.relPath, c.size, fmt.Sprint(c.mtime), c.inode, c.ext)
|
|
if err != nil {
|
|
return fmt.Errorf("upsert file: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func markItemMissing(ctx context.Context, tx *sql.Tx, libraryID int64, key, now string) error {
|
|
_, err := tx.ExecContext(ctx, `
|
|
UPDATE media_items SET missing = 1
|
|
WHERE library_id = ? AND path = ?`, libraryID, key)
|
|
return err
|
|
}
|
|
|
|
func markFileMissing(ctx context.Context, tx *sql.Tx, rel string) error {
|
|
_, err := tx.ExecContext(ctx, `UPDATE media_files SET missing = 1 WHERE path = ?`, rel)
|
|
return err
|
|
}
|
|
|
|
func sortedGroupKeys(groups map[string]group) []string {
|
|
keys := make([]string, 0, len(groups))
|
|
for k := range groups {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func sortedCandidateKeys(found map[string]candidate) []string {
|
|
keys := make([]string, 0, len(found))
|
|
for k := range found {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
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()
|
|
}
|