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}