From 1b11867efedc1444bbac867f282f8c035ef9bbc7 Mon Sep 17 00:00:00 2001 From: mithral Date: Fri, 9 Oct 2026 21:20:47 -0400 Subject: [PATCH] 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. --- internal/library/registry.go | 35 ++- internal/library/scan.go | 205 ++++++++++--- internal/library/scan_test.go | 75 +++++ internal/media/handlers.go | 42 +++ internal/modules/comics/comic.go | 40 +-- internal/modules/comics/comic_test.go | 19 -- internal/natsort/natsort.go | 42 +++ internal/natsort/natsort_test.go | 28 ++ web/src/lib/api.ts | 8 +- web/src/lib/components/AudiobookPlayer.svelte | 285 ++++++++++++++++++ web/src/lib/components/Reader.svelte | 3 + 11 files changed, 671 insertions(+), 111 deletions(-) create mode 100644 internal/natsort/natsort.go create mode 100644 internal/natsort/natsort_test.go create mode 100644 web/src/lib/components/AudiobookPlayer.svelte diff --git a/internal/library/registry.go b/internal/library/registry.go index 114bc41..b20538a 100644 --- a/internal/library/registry.go +++ b/internal/library/registry.go @@ -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() diff --git a/internal/library/scan.go b/internal/library/scan.go index 3542814..da8db5a 100644 --- a/internal/library/scan.go +++ b/internal/library/scan.go @@ -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++ { diff --git a/internal/library/scan_test.go b/internal/library/scan_test.go index ffa7a05..be37c2b 100644 --- a/internal/library/scan_test.go +++ b/internal/library/scan_test.go @@ -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() diff --git a/internal/media/handlers.go b/internal/media/handlers.go index ec24f11..aec7290 100644 --- a/internal/media/handlers.go +++ b/internal/media/handlers.go @@ -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 { diff --git a/internal/modules/comics/comic.go b/internal/modules/comics/comic.go index eeeae19..971ee44 100644 --- a/internal/modules/comics/comic.go +++ b/internal/modules/comics/comic.go @@ -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. diff --git a/internal/modules/comics/comic_test.go b/internal/modules/comics/comic_test.go index e241ed0..d48a793 100644 --- a/internal/modules/comics/comic_test.go +++ b/internal/modules/comics/comic_test.go @@ -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) - } - } -} diff --git a/internal/natsort/natsort.go b/internal/natsort/natsort.go new file mode 100644 index 0000000..07f41a2 --- /dev/null +++ b/internal/natsort/natsort.go @@ -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' } diff --git a/internal/natsort/natsort_test.go b/internal/natsort/natsort_test.go new file mode 100644 index 0000000..6ccc893 --- /dev/null +++ b/internal/natsort/natsort_test.go @@ -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) + } + } +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d2cae61..8bd40bb 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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'), diff --git a/web/src/lib/components/AudiobookPlayer.svelte b/web/src/lib/components/AudiobookPlayer.svelte new file mode 100644 index 0000000..752f1e9 --- /dev/null +++ b/web/src/lib/components/AudiobookPlayer.svelte @@ -0,0 +1,285 @@ + + +{#if error} +
{error}
+{:else if loading} +
+{:else} +
+
+ (e.currentTarget as HTMLImageElement).style.display = 'none'} /> +
+ {item?.title} + {#if tracks.length > 1} + Track {index + 1} of {tracks.length} + {/if} +
+
+ + + + + +
+ {fmt(currentTime)} + {fmt(duration)} +
+ +
+ + + +
+ + {#if tracks.length > 1} +
    + {#each tracks as t, i (t.id)} +
  1. + +
  2. + {/each} +
+ {/if} +
+{/if} + + diff --git a/web/src/lib/components/Reader.svelte b/web/src/lib/components/Reader.svelte index 12e0d94..015ee3b 100644 --- a/web/src/lib/components/Reader.svelte +++ b/web/src/lib/components/Reader.svelte @@ -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 @@
{error}
{:else if item && item.kind === 'comics'} +{:else if item && (item.kind === 'audiobooks' || item.kind === 'music')} + {:else if item && item.kind !== 'ebooks'}

{item.title}