Phase 6a: directory grouping, audiobooks and music playback
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.
This commit is contained in:
parent
df740d3e1a
commit
1b11867efe
11 changed files with 671 additions and 111 deletions
|
|
@ -14,6 +14,17 @@ import (
|
|||
"sync"
|
||||
)
|
||||
|
||||
// GroupMode says how files become items.
|
||||
type GroupMode string
|
||||
|
||||
const (
|
||||
// GroupByFile makes one item per file. Correct for epubs, comics, photos.
|
||||
GroupByFile GroupMode = "file"
|
||||
// GroupByDirectory makes one item per directory, with its files as tracks.
|
||||
// Correct for audiobooks (a folder of chapters) and albums.
|
||||
GroupByDirectory GroupMode = "directory"
|
||||
)
|
||||
|
||||
// MediaType declares one kind of library and the file extensions it owns.
|
||||
type MediaType struct {
|
||||
// Kind is the value stored in libraries.kind and media_items.kind.
|
||||
|
|
@ -22,18 +33,20 @@ type MediaType struct {
|
|||
Name string
|
||||
// Extensions are lowercase, with a leading dot (".epub").
|
||||
Extensions []string
|
||||
// Group controls whether files collapse into one item per directory.
|
||||
Group GroupMode
|
||||
}
|
||||
|
||||
// builtinTypes are the media types Vellum understands out of the box. Modules
|
||||
// may add more at runtime with RegisterType; these exist so the core is
|
||||
// useful before any module is wired up.
|
||||
var builtinTypes = []MediaType{
|
||||
{Kind: "ebooks", Name: "Ebooks", Extensions: []string{".epub", ".mobi", ".azw3", ".fb2"}},
|
||||
{Kind: "comics", Name: "Comics", Extensions: []string{".cbz", ".cbr", ".pdf"}},
|
||||
{Kind: "audiobooks", Name: "Audiobooks", Extensions: []string{".m4b", ".mp3", ".m4a", ".opus", ".flac", ".ogg"}},
|
||||
{Kind: "music", Name: "Music", Extensions: []string{".mp3", ".flac", ".m4a", ".opus", ".ogg", ".wav"}},
|
||||
{Kind: "photos", Name: "Photos", Extensions: []string{".jpg", ".jpeg", ".png", ".webp", ".heic", ".gif", ".avif"}},
|
||||
{Kind: "video", Name: "Video", Extensions: []string{".mkv", ".mp4", ".webm", ".avi", ".mov"}},
|
||||
{Kind: "ebooks", Name: "Ebooks", Extensions: []string{".epub", ".mobi", ".azw3", ".fb2"}, Group: GroupByFile},
|
||||
{Kind: "comics", Name: "Comics", Extensions: []string{".cbz", ".cbr", ".pdf"}, Group: GroupByFile},
|
||||
{Kind: "audiobooks", Name: "Audiobooks", Extensions: []string{".m4b", ".mp3", ".m4a", ".opus", ".flac", ".ogg"}, Group: GroupByDirectory},
|
||||
{Kind: "music", Name: "Music", Extensions: []string{".mp3", ".flac", ".m4a", ".opus", ".ogg", ".wav"}, Group: GroupByDirectory},
|
||||
{Kind: "photos", Name: "Photos", Extensions: []string{".jpg", ".jpeg", ".png", ".webp", ".heic", ".gif", ".avif"}, Group: GroupByFile},
|
||||
{Kind: "video", Name: "Video", Extensions: []string{".mkv", ".mp4", ".webm", ".avi", ".mov"}, Group: GroupByFile},
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -104,6 +117,16 @@ func ValidateKind(kind string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// GroupModeFor returns how a library kind turns files into items.
|
||||
func GroupModeFor(kind string) GroupMode {
|
||||
typesMu.RLock()
|
||||
defer typesMu.RUnlock()
|
||||
if t, ok := types[kind]; ok && t.Group != "" {
|
||||
return t.Group
|
||||
}
|
||||
return GroupByFile
|
||||
}
|
||||
|
||||
// extensionsFor returns the extension set owned by a library kind.
|
||||
func extensionsFor(kind string) map[string]bool {
|
||||
typesMu.RLock()
|
||||
|
|
|
|||
|
|
@ -7,10 +7,14 @@ import (
|
|||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mithral/vellum/internal/natsort"
|
||||
)
|
||||
|
||||
// ScanResult summarises one scan pass.
|
||||
|
|
@ -35,7 +39,7 @@ type candidate struct {
|
|||
ext string
|
||||
}
|
||||
|
||||
// knownFile is an existing row for this library.
|
||||
// knownFile is an existing media_files row for this library.
|
||||
type knownFile struct {
|
||||
itemID int64
|
||||
fileID int64
|
||||
|
|
@ -44,6 +48,13 @@ type knownFile struct {
|
|||
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
|
||||
|
|
@ -59,17 +70,20 @@ func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanR
|
|||
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)
|
||||
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
|
||||
}
|
||||
|
||||
// 2. What is on disk?
|
||||
found, errs := walkRoot(lib.RootPath, exts)
|
||||
res.Errors = errs
|
||||
|
||||
// 3. Reconcile in one transaction.
|
||||
groups := buildGroups(lib, found)
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return res, err
|
||||
|
|
@ -77,69 +91,124 @@ func Scan(ctx context.Context, db *sql.DB, lib Library, log *slog.Logger) (ScanR
|
|||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
for rel, c := range found {
|
||||
res.Scanned++
|
||||
prev, exists := known[rel]
|
||||
seenFiles := make(map[string]bool, len(found))
|
||||
|
||||
switch {
|
||||
case exists && !prev.missing && prev.size == c.size && prev.mtime == c.mtime:
|
||||
res.Unchanged++
|
||||
continue
|
||||
for _, key := range sortedGroupKeys(groups) {
|
||||
g := groups[key]
|
||||
|
||||
case exists && prev.missing:
|
||||
res.Restored++
|
||||
case exists:
|
||||
res.Updated++
|
||||
default:
|
||||
res.Added++
|
||||
}
|
||||
|
||||
itemID, err := upsertItem(ctx, tx, lib, rel, c)
|
||||
itemID, err := upsertItem(ctx, tx, lib, g.key, g.title)
|
||||
if err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", rel, err))
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", g.key, err))
|
||||
continue
|
||||
}
|
||||
if err := upsertFile(ctx, tx, itemID, rel, c); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", rel, 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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Anything we knew about that is gone.
|
||||
for rel := range known {
|
||||
if _, ok := found[rel]; ok {
|
||||
// Items whose group is gone entirely.
|
||||
for key := range knownItems {
|
||||
if _, ok := groups[key]; ok {
|
||||
continue
|
||||
}
|
||||
if err := markMissing(ctx, tx, lib.ID, rel, now); err != nil {
|
||||
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", rel, err))
|
||||
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,
|
||||
"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(path string, d fs.DirEntry, err error) error {
|
||||
err := filepath.WalkDir(root, func(p 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.
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", p, err))
|
||||
if d != nil && d.IsDir() {
|
||||
return fs.SkipDir
|
||||
}
|
||||
|
|
@ -148,7 +217,7 @@ func walkRoot(root string, exts map[string]bool) (map[string]candidate, []string
|
|||
|
||||
name := d.Name()
|
||||
if d.IsDir() {
|
||||
if path != root && strings.HasPrefix(name, ".") {
|
||||
if p != root && strings.HasPrefix(name, ".") {
|
||||
return fs.SkipDir
|
||||
}
|
||||
return nil
|
||||
|
|
@ -164,16 +233,16 @@ func walkRoot(root string, exts map[string]bool) (map[string]candidate, []string
|
|||
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", path, err))
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", p, err))
|
||||
return nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(root, path)
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", path, err))
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", p, err))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -224,8 +293,27 @@ func loadKnownFiles(ctx context.Context, db *sql.DB, libraryID int64) (map[strin
|
|||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, rel string, c candidate) (int64, error) {
|
||||
title := CleanTitle(rel)
|
||||
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)
|
||||
|
|
@ -233,7 +321,7 @@ func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, rel string, c cand
|
|||
ON CONFLICT (library_id, path)
|
||||
DO UPDATE SET missing = 0
|
||||
RETURNING id`,
|
||||
lib.ID, lib.Kind, rel, title, strings.ToLower(title),
|
||||
lib.ID, lib.Kind, key, title, strings.ToLower(title),
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("upsert item: %w", err)
|
||||
|
|
@ -241,7 +329,7 @@ func upsertItem(ctx context.Context, tx *sql.Tx, lib Library, rel string, c cand
|
|||
return id, nil
|
||||
}
|
||||
|
||||
func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, rel string, c candidate) error {
|
||||
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)
|
||||
|
|
@ -249,24 +337,43 @@ func upsertFile(ctx context.Context, tx *sql.Tx, itemID int64, rel string, c can
|
|||
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)
|
||||
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 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
|
||||
}
|
||||
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, rel)
|
||||
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++ {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,81 @@ func TestScanLifecycle(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestScanGroupsDirectoryKinds(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
root := t.TempDir()
|
||||
|
||||
// An audiobook is a folder of chapters.
|
||||
writeFile(t, filepath.Join(root, "Author", "Book", "01 - Chapter One.mp3"), "a")
|
||||
writeFile(t, filepath.Join(root, "Author", "Book", "02 - Chapter Two.mp3"), "b")
|
||||
writeFile(t, filepath.Join(root, "Author", "Book", "10 - Chapter Ten.mp3"), "c")
|
||||
// A loose file at the root groups under the library name.
|
||||
writeFile(t, filepath.Join(root, "stray.mp3"), "d")
|
||||
|
||||
lib, err := Create(ctx, db, "Audiobooks", "audiobooks", root)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
res, err := Scan(ctx, db, lib, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("Scan: %v", err)
|
||||
}
|
||||
if res.Added != 4 {
|
||||
t.Errorf("added = %d, want 4 (four tracks)", res.Added)
|
||||
}
|
||||
|
||||
// Two items: the directory, and the root group.
|
||||
var items int
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT count(*) FROM media_items WHERE library_id = ?`, lib.ID).Scan(&items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if items != 2 {
|
||||
t.Fatalf("items = %d, want 2", items)
|
||||
}
|
||||
|
||||
var title string
|
||||
if err := db.QueryRowContext(ctx,
|
||||
`SELECT title FROM media_items WHERE library_id = ? AND path = ?`, lib.ID, "Author/Book").Scan(&title); err != nil {
|
||||
t.Fatalf("grouped item not found: %v", err)
|
||||
}
|
||||
if title != "Book" {
|
||||
t.Errorf("grouped title = %q, want Book", title)
|
||||
}
|
||||
|
||||
// Tracks must come back in natural order, not lexical.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT f.path FROM media_files f JOIN media_items i ON i.id = f.item_id
|
||||
WHERE i.path = ? ORDER BY f.id`, "Author/Book")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var paths []string
|
||||
for rows.Next() {
|
||||
var p string
|
||||
if err := rows.Scan(&p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths = append(paths, p)
|
||||
}
|
||||
want := []string{
|
||||
"Author/Book/01 - Chapter One.mp3",
|
||||
"Author/Book/02 - Chapter Two.mp3",
|
||||
"Author/Book/10 - Chapter Ten.mp3",
|
||||
}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("track count = %d, want %d: %v", len(paths), len(want), paths)
|
||||
}
|
||||
for i := range want {
|
||||
if paths[i] != want[i] {
|
||||
t.Errorf("track %d = %q, want %q", i, paths[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanUnreadableRoot(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/mithral/vellum/internal/auth"
|
||||
|
|
@ -32,6 +33,7 @@ func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator, cacheDir s
|
|||
func (h *Handlers) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/items/{id}", h.auth.Require(h.handleItem))
|
||||
mux.Handle("GET /api/v1/items/{id}/file", h.auth.Require(h.handleFile))
|
||||
mux.Handle("GET /api/v1/items/{id}/files/{fileID}", h.auth.Require(h.handleFileByID))
|
||||
mux.Handle("GET /api/v1/items/{id}/cover", h.auth.Require(h.handleCover))
|
||||
mux.Handle("GET /api/v1/items/{id}/progress", h.auth.Require(h.handleGetProgress))
|
||||
mux.Handle("PUT /api/v1/items/{id}/progress", h.auth.Require(h.handlePutProgress))
|
||||
|
|
@ -107,6 +109,46 @@ func (h *Handlers) handleFile(w http.ResponseWriter, r *http.Request, u auth.Use
|
|||
}
|
||||
}
|
||||
|
||||
// handleFileByID streams one specific file of an item, for multi-file items
|
||||
// such as audiobook chapters or album tracks.
|
||||
func (h *Handlers) handleFileByID(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
it, ok := h.authorized(w, r, u)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
fileID, err := strconv.ParseInt(r.PathValue("fileID"), 10, 64)
|
||||
if err != nil || fileID <= 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid file id")
|
||||
return
|
||||
}
|
||||
|
||||
// Confirm the file belongs to this item, so an item id cannot be paired
|
||||
// with another item's file id.
|
||||
var relPath string
|
||||
err = h.db.QueryRowContext(r.Context(),
|
||||
`SELECT path FROM media_files WHERE id = ? AND item_id = ?`, fileID, it.ID).Scan(&relPath)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
httpx.WriteError(w, http.StatusNotFound, "file not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
h.fail(w, "load file", err)
|
||||
return
|
||||
}
|
||||
|
||||
abs := filepath.Join(it.RootPath, filepath.FromSlash(relPath))
|
||||
w.Header().Set("Content-Disposition", "inline")
|
||||
if err := stream.ServeFile(w, r, abs); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
httpx.WriteError(w, http.StatusNotFound, "file is not available")
|
||||
return
|
||||
}
|
||||
h.fail(w, "serve file", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) handleCover(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
it, ok := h.authorized(w, r, u)
|
||||
if !ok {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import (
|
|||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mithral/vellum/internal/natsort"
|
||||
)
|
||||
|
||||
// Kind is the media kind this module owns.
|
||||
|
|
@ -69,7 +71,7 @@ func ListPages(cbzPath string) ([]Page, error) {
|
|||
return nil, fmt.Errorf("comics: archive contains no images")
|
||||
}
|
||||
|
||||
sort.Slice(names, func(i, j int) bool { return naturalLess(names[i], names[j]) })
|
||||
sort.Slice(names, func(i, j int) bool { return natsort.Less(names[i], names[j]) })
|
||||
|
||||
pages := make([]Page, len(names))
|
||||
for i, n := range names {
|
||||
|
|
@ -103,37 +105,5 @@ func ReadPage(cbzPath, name string) ([]byte, error) {
|
|||
return nil, fmt.Errorf("comics: page %q not found", name)
|
||||
}
|
||||
|
||||
// naturalLess compares two strings treating digit runs as numbers.
|
||||
func naturalLess(a, b string) bool {
|
||||
ai, bi := 0, 0
|
||||
for ai < len(a) && bi < len(b) {
|
||||
ad, bd := isDigit(a[ai]), isDigit(b[bi])
|
||||
if ad && bd {
|
||||
// Compare the full digit runs numerically.
|
||||
as, bs := ai, bi
|
||||
for ai < len(a) && isDigit(a[ai]) {
|
||||
ai++
|
||||
}
|
||||
for bi < len(b) && isDigit(b[bi]) {
|
||||
bi++
|
||||
}
|
||||
an := strings.TrimLeft(a[as:ai], "0")
|
||||
bn := strings.TrimLeft(b[bs:bi], "0")
|
||||
if len(an) != len(bn) {
|
||||
return len(an) < len(bn)
|
||||
}
|
||||
if an != bn {
|
||||
return an < bn
|
||||
}
|
||||
continue
|
||||
}
|
||||
if a[ai] != b[bi] {
|
||||
return a[ai] < b[bi]
|
||||
}
|
||||
ai++
|
||||
bi++
|
||||
}
|
||||
return len(a)-ai < len(b)-bi
|
||||
}
|
||||
|
||||
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
|
||||
// naturalLess moved to internal/natsort, shared with the scanner's track
|
||||
// ordering.
|
||||
|
|
|
|||
|
|
@ -81,22 +81,3 @@ func TestListPagesRejectsArchiveWithoutImages(t *testing.T) {
|
|||
t.Error("ListPages succeeded on an archive with no images")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNaturalLess(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"page1.jpg", "page2.jpg", true},
|
||||
{"page2.jpg", "page10.jpg", true},
|
||||
{"page10.jpg", "page2.jpg", false},
|
||||
{"page01.jpg", "page1.jpg", false}, // equal numerically
|
||||
{"page1.jpg", "page01.jpg", false},
|
||||
{"a.jpg", "b.jpg", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := naturalLess(c.a, c.b); got != c.want {
|
||||
t.Errorf("naturalLess(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
internal/natsort/natsort.go
Normal file
42
internal/natsort/natsort.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Package natsort compares strings the way humans number things: "page2" sorts
|
||||
// before "page10". Lexical ordering gets this wrong, which silently shuffles
|
||||
// comic pages and audiobook tracks.
|
||||
package natsort
|
||||
|
||||
import "strings"
|
||||
|
||||
// Less reports whether a sorts before b.
|
||||
func Less(a, b string) bool {
|
||||
ai, bi := 0, 0
|
||||
for ai < len(a) && bi < len(b) {
|
||||
ad, bd := isDigit(a[ai]), isDigit(b[bi])
|
||||
if ad && bd {
|
||||
// Compare the whole digit runs as numbers, ignoring leading zeros
|
||||
// so "01" and "1" are equal.
|
||||
as, bs := ai, bi
|
||||
for ai < len(a) && isDigit(a[ai]) {
|
||||
ai++
|
||||
}
|
||||
for bi < len(b) && isDigit(b[bi]) {
|
||||
bi++
|
||||
}
|
||||
an := strings.TrimLeft(a[as:ai], "0")
|
||||
bn := strings.TrimLeft(b[bs:bi], "0")
|
||||
if len(an) != len(bn) {
|
||||
return len(an) < len(bn)
|
||||
}
|
||||
if an != bn {
|
||||
return an < bn
|
||||
}
|
||||
continue
|
||||
}
|
||||
if a[ai] != b[bi] {
|
||||
return a[ai] < b[bi]
|
||||
}
|
||||
ai++
|
||||
bi++
|
||||
}
|
||||
return len(a)-ai < len(b)-bi
|
||||
}
|
||||
|
||||
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
|
||||
28
internal/natsort/natsort_test.go
Normal file
28
internal/natsort/natsort_test.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package natsort
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLess(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"page1", "page2", true},
|
||||
{"page2", "page10", true},
|
||||
{"page10", "page2", false},
|
||||
{"01 - Track.mp3", "02 - Track.mp3", true},
|
||||
{"Chapter 1.mp3", "Chapter 10.mp3", true},
|
||||
{"Chapter 10.mp3", "Chapter 9.mp3", false},
|
||||
{"page01", "page1", false}, // numerically equal
|
||||
{"page1", "page01", false},
|
||||
{"a", "b", true},
|
||||
{"b", "a", false},
|
||||
{"", "a", true},
|
||||
{"a", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Less(c.a, c.b); got != c.want {
|
||||
t.Errorf("Less(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -149,10 +149,14 @@ export const api = {
|
|||
|
||||
fileUrl: (id: number) => `/api/v1/items/${id}/file`,
|
||||
|
||||
fileUrlById: (id: number, fileId: number) => `/api/v1/items/${id}/files/${fileId}`,
|
||||
|
||||
progress: (id: number) => request<{ progress: Progress | null }>('GET', `/api/v1/items/${id}/progress`),
|
||||
|
||||
putProgress: (id: number, p: { position?: number; duration?: number; percent?: number; finished?: boolean }) =>
|
||||
request<{ progress: Progress }>('PUT', `/api/v1/items/${id}/progress`, p),
|
||||
putProgress: (
|
||||
id: number,
|
||||
p: { file_id?: number; position?: number; duration?: number; percent?: number; finished?: boolean },
|
||||
) => request<{ progress: Progress }>('PUT', `/api/v1/items/${id}/progress`, p),
|
||||
|
||||
recent: () => request<{ progress: Progress[] }>('GET', '/api/v1/progress/recent'),
|
||||
|
||||
|
|
|
|||
285
web/src/lib/components/AudiobookPlayer.svelte
Normal file
285
web/src/lib/components/AudiobookPlayer.svelte
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { api, ApiError, type Item } from '../api'
|
||||
|
||||
let { itemId }: { itemId: number } = $props()
|
||||
|
||||
let audio = $state<HTMLAudioElement>()
|
||||
let item = $state<Item | null>(null)
|
||||
let tracks = $state<{ id: number; path: string; size: number }[]>([])
|
||||
let index = $state(0)
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
let playing = $state(false)
|
||||
let currentTime = $state(0)
|
||||
let duration = $state(0)
|
||||
|
||||
let saveTimer: number | undefined
|
||||
let resumeAt = 0
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const detail = await api.item(itemId)
|
||||
item = detail.item
|
||||
tracks = detail.files ?? []
|
||||
if (tracks.length === 0) {
|
||||
error = 'This item has no audio tracks.'
|
||||
return
|
||||
}
|
||||
// Resume on the track that was playing, at the saved offset.
|
||||
if (detail.progress) {
|
||||
const i = tracks.findIndex((t) => t.id === detail.progress!.file_id)
|
||||
if (i >= 0) {
|
||||
index = i
|
||||
resumeAt = detail.progress.position / 1000
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'could not open this audiobook'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
flush()
|
||||
})
|
||||
|
||||
function onLoadedMetadata() {
|
||||
if (!audio) return
|
||||
duration = audio.duration || 0
|
||||
if (resumeAt > 0 && resumeAt < duration) {
|
||||
audio.currentTime = resumeAt
|
||||
resumeAt = 0
|
||||
}
|
||||
}
|
||||
|
||||
function onTimeUpdate() {
|
||||
if (!audio) return
|
||||
currentTime = audio.currentTime
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (!audio) return
|
||||
if (audio.paused) void audio.play()
|
||||
else audio.pause()
|
||||
}
|
||||
|
||||
function onPlay() {
|
||||
playing = true
|
||||
}
|
||||
function onPause() {
|
||||
playing = false
|
||||
}
|
||||
|
||||
function goToTrack(i: number) {
|
||||
if (i < 0 || i >= tracks.length) return
|
||||
flush()
|
||||
index = i
|
||||
resumeAt = 0
|
||||
// Let Svelte swap the src, then start playback.
|
||||
queueMicrotask(() => {
|
||||
if (audio) void audio.play().catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
function onEnded() {
|
||||
if (index + 1 < tracks.length) goToTrack(index + 1)
|
||||
else flush(true)
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = window.setTimeout(() => flush(), 2000)
|
||||
}
|
||||
|
||||
// Progress is milliseconds, and is keyed by file, so a multi-track book
|
||||
// resumes exactly where it stopped.
|
||||
function flush(finished = false) {
|
||||
if (!audio || tracks.length === 0) return
|
||||
const track = tracks[index]
|
||||
if (!track) return
|
||||
const pos = Math.floor(audio.currentTime * 1000)
|
||||
const dur = Math.floor((audio.duration || 0) * 1000)
|
||||
void api
|
||||
.putProgress(itemId, {
|
||||
file_id: track.id,
|
||||
position: pos,
|
||||
duration: dur,
|
||||
percent: dur > 0 ? pos / dur : 0,
|
||||
finished,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function seek(event: Event) {
|
||||
const value = Number((event.currentTarget as HTMLInputElement).value)
|
||||
if (audio) audio.currentTime = value
|
||||
currentTime = value
|
||||
}
|
||||
|
||||
function fmt(seconds: number): string {
|
||||
if (!isFinite(seconds) || seconds < 0) seconds = 0
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
const mm = String(m).padStart(2, '0')
|
||||
const ss = String(s).padStart(2, '0')
|
||||
return h > 0 ? `${h}:${mm}:${ss}` : `${m}:${ss}`
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div class="alert">{error}</div>
|
||||
{:else if loading}
|
||||
<div class="center"><div class="spinner"></div></div>
|
||||
{:else}
|
||||
<div class="player card">
|
||||
<div class="head">
|
||||
<img class="cover" src={api.coverUrl(itemId)} alt="" onerror={(e) => (e.currentTarget as HTMLImageElement).style.display = 'none'} />
|
||||
<div class="meta">
|
||||
<strong>{item?.title}</strong>
|
||||
{#if tracks.length > 1}
|
||||
<span class="muted">Track {index + 1} of {tracks.length}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio
|
||||
bind:this={audio}
|
||||
src={api.fileUrlById(itemId, tracks[index].id)}
|
||||
preload="metadata"
|
||||
onloadedmetadata={onLoadedMetadata}
|
||||
ontimeupdate={onTimeUpdate}
|
||||
onplay={onPlay}
|
||||
onpause={onPause}
|
||||
onended={onEnded}
|
||||
></audio>
|
||||
|
||||
<input
|
||||
class="seek"
|
||||
type="range"
|
||||
min="0"
|
||||
max={duration || 0}
|
||||
step="1"
|
||||
value={currentTime}
|
||||
oninput={seek}
|
||||
aria-label="Seek"
|
||||
/>
|
||||
|
||||
<div class="times mono">
|
||||
<span>{fmt(currentTime)}</span>
|
||||
<span class="muted">{fmt(duration)}</span>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn btn-ghost" onclick={() => goToTrack(index - 1)} disabled={index <= 0}>⏮</button>
|
||||
<button class="btn btn-primary play" onclick={toggle}>{playing ? 'Pause' : 'Play'}</button>
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
onclick={() => goToTrack(index + 1)}
|
||||
disabled={index >= tracks.length - 1}>⏭</button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if tracks.length > 1}
|
||||
<ol class="tracks">
|
||||
{#each tracks as t, i (t.id)}
|
||||
<li>
|
||||
<button class="track" class:current={i === index} onclick={() => goToTrack(i)}>
|
||||
<span class="mono">{String(i + 1).padStart(2, '0')}</span>
|
||||
<span class="name">{t.path.split('/').pop()}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.center {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
.player {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.cover {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.seek {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
.times {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.play {
|
||||
min-width: 96px;
|
||||
justify-content: center;
|
||||
}
|
||||
.tracks {
|
||||
list-style: none;
|
||||
margin: var(--space-2) 0 0;
|
||||
padding: 0;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.track {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: 7px var(--space-2);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.track:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
.track.current {
|
||||
color: var(--accent);
|
||||
}
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { onMount, onDestroy } from 'svelte'
|
||||
import { api, ApiError, type Item, type EbookMetadata } from '../api'
|
||||
import ComicReader from './ComicReader.svelte'
|
||||
import AudiobookPlayer from './AudiobookPlayer.svelte'
|
||||
|
||||
let { itemId }: { itemId: number } = $props()
|
||||
|
||||
|
|
@ -92,6 +93,8 @@
|
|||
<div class="alert">{error}</div>
|
||||
{:else if item && item.kind === 'comics'}
|
||||
<ComicReader {itemId} />
|
||||
{:else if item && (item.kind === 'audiobooks' || item.kind === 'music')}
|
||||
<AudiobookPlayer {itemId} />
|
||||
{:else if item && item.kind !== 'ebooks'}
|
||||
<div class="empty">
|
||||
<h3>{item.title}</h3>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue