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:
mithral 2026-10-09 21:20:47 -04:00
parent df740d3e1a
commit 1b11867efe
11 changed files with 671 additions and 111 deletions

View file

@ -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()

View file

@ -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:
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
}
case exists && prev.missing:
res.Restored++
case exists:
res.Updated++
default:
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++
}
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 {
// 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++ {

View file

@ -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()

View file

@ -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 {

View file

@ -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.

View file

@ -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)
}
}
}

View 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' }

View 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)
}
}
}

View file

@ -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'),

View 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>

View file

@ -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>