From 6216bd9bcfa9e26437e89e375f2d15613cb82a8d Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Mon, 19 Oct 2026 07:54:28 -0400 Subject: [PATCH] feat(audiobooks): multi-file books (folder-of-tracks + zip) + exhaustive metadata ingestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-file book ingest: a directory of ≥2 audio files (recursively — disc-part folders like CD1/Disc 2 merge upward, siblings never merge) ingests as ONE book whose file_path is the book-root directory; a zip of audio tracks is its own book (file_path = the archive). Both write the documented chapters abstraction {index,title,start,end} into audio_metadata and add additive keys: layout ("folder"|"zip") and a tracks[] manifest (per-track path, global-seconds start/end, duration, size). Pre-grouping one-track strays under a book root are absorbed on rescan (annotations cascade). Rescans take a stat-only fast path (track count + total size + known durations unchanged → no re-probe — a 1189-track book must not re-ffprobe every auto-scan); a zero-duration book counts as changed so a ffprobe-equipped rescan fills durations in. Folder-book roots count as present in the archive lifecycle pass, and per-item rescan (RescanMediaItem) routes multi-file books through a book-level refresh — the generic updater would read the root/zip as if it were the book file and clobber format_group with "unknown". Serving: GET /api/media-items/:id/chapters gains additive layout + tracks keys (urls → the new endpoint). New GET /api/media-items/:id/audio/:trackIndex streams one track — folder tracks served from the real file (full Range via ServeContent), zip tracks via a reopen-and-skip ReadSeeker over the entry (deflate has no random access; acceptable at audio bitrates; stored entries stay section readers). Both endpoints gate on library visibility (403 on hidden libraries — family-lens rule). ServeFile and OPDS DownloadBook refuse directory paths (folder books have no single-file download). Web player (next commit) consumes the manifest; progress/bookmarks/ deep-links keep their global-seconds coordinates, so the server-side progress model is untouched. sync learns FormatGroupAudiobook (percentage passthrough in ConvertProgress); detectFormatType stops reporting "unknown" for audio. Metadata ingestion (Batch B) — read everything available, degrade gracefully, documented precedence sidecars → folder structure → embedded tags → filename: ffprobe tags are now a case-insensitive lookup map (freeform keys pass through verbatim); composer fills narrators (Audible's own convention, kept as ONE string — no reliable multi-narrator delimiter); grouping + Audible freeform atoms (SERIES/SERIES-PART/SUBTITLE/ASIN/ABRIDGED) fill series/index/ subtitle-join/ASIN/abridged (explicit sidecar abridged-false is guarded); date/genre fall back to tags; album/album_artist/artist fill title/author. Genre persists at create for all formats. Embedded cover art (mp3 APIC, m4b covr, flac picture) extracts via one optional ffmpeg call as a ".cover.jpg" sibling — sidecar covers win. CUE sheets (FILE-matched, INDEX 01 at 75fps) are the lowest- priority chapter source under sidecars and embedded m4b chapters. A lone m3u/m3u8 in a book root imposes playlist ordering + #EXTINF names; (disc, track) tags reorder tracks when coverage is complete. extractMetadata's default branch no longer pre-fills the filename as title (tags must outrank it) — final filename fallbacks now exist on BOTH the create and rescan paths (TestRescanKeepsFilenameTitle pins that rescans cannot blank a title); tagless zip books take the zip's own name, not their parent folder's. Accepted containers widen to .m4b .m4a .mp3 .flac .zip .opus .ogg .oga .aac .wav with real media types registered (audio/ogg per RFC 7845 for Opus-in-Ogg) — all browser-playable; .wma deliberately excluded. Tests: unit tests for CUE parsing, playlist hints, tag fallbacks (synthetic ffprobe reports — ffmpeg cannot write Audible freeform atoms), ordinal parsing, tag ordering, natural sort, grouping rules; integration suite (11 tests) over the live dev DB: folder/zip ingest + idempotency, stray absorption, chapters manifest, per-track streaming with Range, hidden-library 403s, embedded covers + sidecar-wins, CUE chapters, embedded m4b chapters, playlist ordering, tag fallbacks, all five new containers with content-type assertions, rescan title pin. Fixture media is real committed-format binary content living untracked under internal/services/testdata/ (gitignored by policy; recipes in docs/contributing/development.md → Media fixtures). Test image now ships ffmpeg to match the runtime's audio toolchain. --- Dockerfile | 5 +- bruno/media-items/Get Audio Chapters.yml | 31 + bruno/media-items/Get Audio Track.yml | 31 + cmd/server/tests/audiobook_multifile_test.go | 571 ++++++++ database/schema/schema.sql | 8 +- docs/contract_pins.md | 17 + .../api/media-items/get_audio_chapters.md | 53 + .../api/media-items/get_audio_track.md | 44 + docs/developer/api/stats/get_library_usage.md | 7 +- docs/developer/scanner/audio-metadata.md | 83 ++ internal/database/querier.go | 5 + internal/database/queries.sql.go | 105 ++ internal/database/queries/queries.sql | 14 + internal/handlers/media.go | 235 +++- internal/handlers/opds.go | 15 +- internal/router/media.go | 7 +- internal/router/router.go | 5 + internal/services/library_service.go | 15 +- internal/services/media_scanner.go | 169 ++- internal/services/media_scanner_audio.go | 227 +++- internal/services/media_scanner_audio_cue.go | 173 +++ .../media_scanner_audio_metadata_test.go | 277 ++++ .../services/media_scanner_audio_multifile.go | 1167 +++++++++++++++++ .../media_scanner_audio_multifile_test.go | 203 +++ internal/sync/format.go | 1 + internal/sync/progress.go | 11 +- 26 files changed, 3419 insertions(+), 60 deletions(-) create mode 100644 bruno/media-items/Get Audio Chapters.yml create mode 100644 bruno/media-items/Get Audio Track.yml create mode 100644 cmd/server/tests/audiobook_multifile_test.go create mode 100644 docs/developer/api/media-items/get_audio_chapters.md create mode 100644 docs/developer/api/media-items/get_audio_track.md create mode 100644 docs/developer/scanner/audio-metadata.md create mode 100644 internal/services/media_scanner_audio_cue.go create mode 100644 internal/services/media_scanner_audio_metadata_test.go create mode 100644 internal/services/media_scanner_audio_multifile.go create mode 100644 internal/services/media_scanner_audio_multifile_test.go diff --git a/Dockerfile b/Dockerfile index cc45ca17..916f9657 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,7 +59,10 @@ RUN cp -r /go/pkg/mod /app/go-module-cache # This stage is ONLY used for running tests, never deployed to production FROM golang:1.26-alpine AS test-runner -RUN apk --no-cache add ca-certificates curl poppler-utils +# ffmpeg/ffprobe match the runtime stage: the audio ingestion (tag +# fallbacks, embedded covers, cue probing) must be exercised in tests +# exactly as it runs in production. +RUN apk --no-cache add ca-certificates curl poppler-utils ffmpeg WORKDIR /app diff --git a/bruno/media-items/Get Audio Chapters.yml b/bruno/media-items/Get Audio Chapters.yml new file mode 100644 index 00000000..e998bdbf --- /dev/null +++ b/bruno/media-items/Get Audio Chapters.yml @@ -0,0 +1,31 @@ +info: + name: Get Audio Chapters + type: http + seq: 30 + +http: + method: GET + url: "{{base_url}}/api/media-items/{{media_item_id}}/chapters" + headers: + - name: "" + value: application/json + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 + +docs: |- + ## Get Audio Chapters + + The format-abstraction chapter list for audiobooks + (media_items.audio_metadata). Single-file books return `layout: ""` and + an empty `tracks` array; folder-of-tracks books return `layout: + "folder"` and zip books `layout: "zip"`, each with one `tracks` entry + per playable file (global-seconds start/end plus the per-track `url`). + + **Method:** GET + + **Endpoint:** /api/media-items/{id}/chapters diff --git a/bruno/media-items/Get Audio Track.yml b/bruno/media-items/Get Audio Track.yml new file mode 100644 index 00000000..0e4f608e --- /dev/null +++ b/bruno/media-items/Get Audio Track.yml @@ -0,0 +1,31 @@ +info: + name: Get Audio Track + type: http + seq: 31 + +http: + method: GET + url: "{{base_url}}/api/media-items/{{media_item_id}}/audio/0" + headers: + - name: Range + value: bytes=0-1023 + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 + +docs: |- + ## Get Audio Track + + Streams one track of a multi-file audiobook (folder or zip layout). + Range requests are honored: folder tracks are served from the real + file; zip tracks are extracted in place (backward seeks re-open the + entry). Requires the item's library to be visible to the requester + (403 otherwise). Single-file books have no track manifest — 404. + + **Method:** GET + + **Endpoint:** /api/media-items/{id}/audio/{trackIndex} diff --git a/cmd/server/tests/audiobook_multifile_test.go b/cmd/server/tests/audiobook_multifile_test.go new file mode 100644 index 00000000..27d29039 --- /dev/null +++ b/cmd/server/tests/audiobook_multifile_test.go @@ -0,0 +1,571 @@ +package main + +// Multi-file audiobook integration tests: folder-of-tracks and zip-of- +// audio books must ingest as ONE item each (never per-track items), the +// /chapters manifest must expose the track list, the per-track streaming +// endpoint must serve bytes behind the library-visibility gate, and +// rescans must be idempotent while absorbing pre-grouping one-track +// strays. Live dev DB — all fixtures use test-named libraries under +// t.TempDir() folders and cascade away with the library delete. + +import ( + "archive/zip" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "bookhoard/internal/database" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/require" +) + +// writeTestTrack renders a short silent mp3 via ffmpeg when available so +// ffprobe can produce real durations; otherwise falls back to dummy bytes +// (ingest is ffprobe-optional by design — structure assertions don't need +// real audio). +func writeTestTrack(t *testing.T, path string) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil { + cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi", + "-i", "sine=frequency=440:duration=0.3", "-codec:a", "libmp3lame", "-b:a", "32k", path) + if out, err := cmd.CombinedOutput(); err == nil { + return + } else { + t.Logf("ffmpeg track render failed (%s), falling back to dummy bytes: %s", err, out) + } + } + require.NoError(t, os.WriteFile(path, []byte("ID3 dummy audio"), 0o644)) +} + +// audioTestLibrary builds an audiobooks-type library with a fresh temp +// folder, returning its UUID and the folder path. +func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) { + t.Helper() + libID, folder := createTypedTestLibrary(t, setup, name, "audiobooks") + return libID, folder +} + +// listLibraryAudioItems returns every media item row of the library. +func listLibraryAudioItems(t *testing.T, setup *TestServerSetup, libID pgtype.UUID) []database.ListMediaItemsByLibraryIncludingArchivedRow { + t.Helper() + items, err := setup.DB.ListMediaItemsByLibraryIncludingArchived(context.Background(), libID) + require.NoError(t, err) + return items +} + +// findAudioItemByPath returns the item with the given file_path. +func findAudioItemByPath(t *testing.T, setup *TestServerSetup, libID pgtype.UUID, filePath string) (database.ListMediaItemsByLibraryIncludingArchivedRow, bool) { + t.Helper() + for _, item := range listLibraryAudioItems(t, setup, libID) { + if item.FilePath == filePath { + return item, true + } + } + return database.ListMediaItemsByLibraryIncludingArchivedRow{}, false +} + +// decodeAudioPayload parses the audio_metadata JSONB of an item. +func decodeAudioPayload(t *testing.T, item database.ListMediaItemsByLibraryIncludingArchivedRow) (layout string, trackCount, chapterCount int, duration float64) { + t.Helper() + require.NotNil(t, item.AudioMetadata, "audio_metadata must be present") + var payload struct { + Layout string `json:"layout"` + DurationSeconds float64 `json:"duration_seconds"` + Tracks []struct { + Index int `json:"index"` + Path string `json:"path"` + } `json:"tracks"` + Chapters []json.RawMessage `json:"chapters"` + } + require.NoError(t, json.Unmarshal(item.AudioMetadata, &payload)) + return payload.Layout, len(payload.Tracks), len(payload.Chapters), payload.DurationSeconds +} + +func TestAudiobookFolderOfTracks(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Folder %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + // Author/Title/01.mp3, 02.mp3 — the canonical folder book. + writeTestTrack(t, filepath.Join(folder, "Test Author", "Test Title", "02 Second.mp3")) + writeTestTrack(t, filepath.Join(folder, "Test Author", "Test Title", "01 First.mp3")) + // A lone single-file book in a sibling folder must stay single-file. + writeTestTrack(t, filepath.Join(folder, "Test Author", "Lone Book", "lonely.mp3")) + + scanTestFolder(t, setup, admin, folder) + + // The folder is ONE item whose file_path is the directory. + book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Test Author/Test Title")) + require.True(t, found, "folder book item must exist at the directory path") + layout, tracks, chapters, duration := decodeAudioPayload(t, book) + require.Equal(t, "folder", layout) + require.Equal(t, 2, tracks) + require.Equal(t, 2, chapters) + if duration <= 0 { + t.Log("no ffprobe durations available in this environment (degraded ingest)") + } + + // The lone mp3 keeps the single-file model. + _, loneFound := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Test Author/Lone Book/lonely.mp3")) + require.True(t, loneFound, "lone mp3 must stay its own single-file item") + require.Len(t, listLibraryAudioItems(t, setup, libID), 2, "exactly two items: one folder book + one single-file book") + + // Rescan idempotency: nothing duplicates, manifest unchanged. + scanTestFolder(t, setup, admin, folder) + require.Len(t, listLibraryAudioItems(t, setup, libID), 2) + again, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Test Author/Test Title")) + require.True(t, found) + require.JSONEq(t, string(book.AudioMetadata), string(again.AudioMetadata)) +} + +func TestAudiobookAbsorbsOneTrackStrays(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Merge %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + // First scan: a single mp3 → single-file item (the pre-grouping model). + writeTestTrack(t, filepath.Join(folder, "Merge Book", "01 only.mp3")) + scanTestFolder(t, setup, admin, folder) + _, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Merge Book/01 only.mp3")) + require.True(t, found, "single track ingests as its own item first") + + // Second track arrives; rescan must create the folder book and delete + // the stray one-track item. + writeTestTrack(t, filepath.Join(folder, "Merge Book", "02 and.mp3")) + scanTestFolder(t, setup, admin, folder) + + book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Merge Book")) + require.True(t, found, "folder book must exist after rescan") + layout, tracks, _, _ := decodeAudioPayload(t, book) + require.Equal(t, "folder", layout) + require.Equal(t, 2, tracks) + + items := listLibraryAudioItems(t, setup, libID) + require.Len(t, items, 1, "the one-track stray must be absorbed into the folder book") +} + +func TestAudiobookZipBook(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Zip %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + // Build a zip of mp3s via ffmpeg-rendered tracks. + dir := t.TempDir() + writeTestTrack(t, filepath.Join(dir, "a.mp3")) + writeTestTrack(t, filepath.Join(dir, "b.mp3")) + zipPath := filepath.Join(folder, "Zipped Book.zip") + zipFiles(t, zipPath, map[string]string{ + "01 first.mp3": filepath.Join(dir, "a.mp3"), + "02 second.mp3": filepath.Join(dir, "b.mp3"), + }) + + scanTestFolder(t, setup, admin, folder) + + book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Zipped Book.zip")) + require.True(t, found, "zip book item must exist at the zip path") + layout, tracks, chapters, _ := decodeAudioPayload(t, book) + require.Equal(t, "zip", layout) + require.Equal(t, 2, tracks) + require.Equal(t, 2, chapters) + + // Rescan idempotency. + scanTestFolder(t, setup, admin, folder) + require.Len(t, listLibraryAudioItems(t, setup, libID), 1) +} + +func TestAudiobookTrackEndpoints(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + + admin, _ := moveTestAdminID(t, setup) + regularID := getRegularUserID(t, setup.DB) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Serve %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + writeTestTrack(t, filepath.Join(folder, "Serve Book", "01 one.mp3")) + writeTestTrack(t, filepath.Join(folder, "Serve Book", "02 two.mp3")) + scanTestFolder(t, setup, admin, folder) + + book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Serve Book")) + require.True(t, found) + bookID := uuid.UUID(book.ID.Bytes) + + client := &http.Client{} + + t.Run("chapters manifest exposes tracks", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/chapters", nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var data struct { + Layout string `json:"layout"` + Tracks []struct { + Index int `json:"index"` + URL string `json:"url"` + Path string `json:"path"` + Start float64 `json:"start"` + } `json:"tracks"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&data)) + require.Equal(t, "folder", data.Layout) + require.Len(t, data.Tracks, 2) + require.Equal(t, "/api/media-items/"+bookID.String()+"/audio/0", data.Tracks[0].URL) + }) + + t.Run("track streams with range support", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio/0", nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + req.Header.Set("Range", "bytes=0-99") + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusPartialContent, resp.StatusCode) + }) + + t.Run("out-of-range track index is 404", func(t *testing.T) { + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio/9", nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + resp, err := client.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("hidden library is 403 on chapters and tracks", func(t *testing.T) { + _, err := setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{ + UserID: pgtype.UUID{Bytes: regularID, Valid: true}, + LibraryID: libID, + IsVisible: false, + }) + require.NoError(t, err) + + for _, path := range []string{ + "/api/media-items/" + bookID.String() + "/chapters", + "/api/media-items/" + bookID.String() + "/audio/0", + } { + req, _ := http.NewRequest("GET", setup.Server.URL+path, nil) + req.Header.Set("Authorization", "Bearer "+setup.RegularToken) + resp, err := client.Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s must be gated", path) + } + + // Restore visibility so harness cleanup ordering never sees a + // stale hidden row for a deleted library's user. + _, err = setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{ + UserID: pgtype.UUID{Bytes: regularID, Valid: true}, + LibraryID: libID, + IsVisible: true, + }) + require.NoError(t, err) + }) +} + +// zipFiles builds a zip archive at zipPath mapping entry names to source +// file paths on disk. +func zipFiles(t *testing.T, zipPath string, entries map[string]string) { + t.Helper() + out, err := os.Create(zipPath) + require.NoError(t, err) + defer func() { _ = out.Close() }() + w := zip.NewWriter(out) + for name, src := range entries { + data, err := os.ReadFile(src) + require.NoError(t, err) + f, err := w.Create(name) + require.NoError(t, err) + _, err = f.Write(data) + require.NoError(t, err) + } + require.NoError(t, w.Close()) +} + +// fixtureAudio copies a committed binary fixture (real tagged media — +// the test container has no way to generate it) into a library folder. +// Path is relative from cmd/server/tests (three deep) to the services +// testdata tree. +func fixtureAudio(t *testing.T, name, dest string) { + t.Helper() + src := filepath.Join("..", "..", "..", "internal", "services", "testdata", "audio", name) + data, err := os.ReadFile(src) + require.NoError(t, err, "fixture %s must exist in the repo", name) + require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755)) + require.NoError(t, os.WriteFile(dest, data, 0o644)) +} + +// getJSONWithAuth fetches an API endpoint with the admin token and +// decodes the body into dst. +func getJSONWithAuth(t *testing.T, setup *TestServerSetup, path string, dst interface{}) int { + t.Helper() + req, _ := http.NewRequest("GET", setup.Server.URL+path, nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + if dst != nil { + require.NoError(t, json.NewDecoder(resp.Body).Decode(dst)) + } + return resp.StatusCode +} + +func TestAudiobookNewContainerFormats(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Formats %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + for _, name := range []string{"book.opus", "book.ogg", "book.oga", "book.aac", "book.wav"} { + // one book per folder — a directory with ≥2 audio files is ONE + // book by the grouping doctrine + fixtureAudio(t, name, filepath.Join(folder, "Formats "+name, name)) + } + scanTestFolder(t, setup, admin, folder) + + items := listLibraryAudioItems(t, setup, libID) + require.Len(t, items, 5, "each new container ingests as its own single-file book") + mimes := map[string]string{} + for _, item := range items { + require.Equal(t, "audiobook", item.FormatGroup, "%s must classify as audiobook", item.FilePath) + mimes[filepath.Ext(item.FilePath)] = item.MimeType.String + } + require.Equal(t, map[string]string{ + ".opus": "audio/ogg", ".ogg": "audio/ogg", ".oga": "audio/ogg", + ".aac": "audio/aac", ".wav": "audio/wav", + }, mimes) + + // The download endpoint must serve the REAL content types — a wrong + // audio/mpeg fallback makes browsers refuse the stream. + for _, name := range []string{"book.opus", "book.aac", "book.wav"} { + item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Formats "+name+"/"+name)) + require.True(t, found, name) + bookID := uuid.UUID(item.ID.Bytes) + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/download", nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + // Drain but discard: a 0.3s sine is a few KB — nothing to save. + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, item.MimeType.String, resp.Header.Get("Content-Type"), "%s content type", name) + } +} + +func TestAudiobookTagFallbacks(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Tags %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + // No sidecars anywhere: the file's own tags are the only source. + fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Tag Author", "The Tagged Album", "tagged.mp3")) + scanTestFolder(t, setup, admin, folder) + + item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Tag Author/The Tagged Album/tagged.mp3")) + require.True(t, found) + require.Equal(t, "The Tagged Album", item.Title, "album tag names the book") + require.Equal(t, "Tag Author", item.Author.String, "album_artist tag names the author") + require.Len(t, item.Narrators, 1, "composer tag fills narrators (Audible convention)") + require.Equal(t, "Tag Narrator", item.Narrators[0]) + require.Equal(t, "Tag Series", item.Series.String, "grouping tag fills series") + require.Equal(t, "Audiobook", item.Genre.String, "genre tag fills genre") + require.False(t, item.DatePublished.Time.IsZero(), "date tag fills publish date") + require.Equal(t, 2020, item.DatePublished.Time.Year()) + // ASIN/abridged/subtitle arrive only through Audible freeform atoms, + // which ffmpeg cannot write into a fixture — those fallbacks are + // pinned by TestApplyAudioTagFallbacks (synthetic reports). +} + +func TestAudiobookCueSheetChapters(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Cue %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + fixtureAudio(t, "cuebook.flac", filepath.Join(folder, "Cue Book", "cuebook.flac")) + fixtureAudio(t, "cuebook.cue", filepath.Join(folder, "Cue Book", "cuebook.cue")) + scanTestFolder(t, setup, admin, folder) + + item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Cue Book/cuebook.flac")) + require.True(t, found) + bookID := uuid.UUID(item.ID.Bytes) + + var data struct { + Chapters []struct { + Index int `json:"index"` + Title string `json:"title"` + Start float64 `json:"start"` + End float64 `json:"end"` + } `json:"chapters"` + } + status := getJSONWithAuth(t, setup, "/api/media-items/"+bookID.String()+"/chapters", &data) + require.Equal(t, http.StatusOK, status) + require.Len(t, data.Chapters, 3, "cue tracks become chapters") + require.Equal(t, "Opening", data.Chapters[0].Title) + require.Equal(t, "The Middle Bit", data.Chapters[1].Title) + require.InDelta(t, 20.0, data.Chapters[1].Start, 0.01) + require.Equal(t, "Closing", data.Chapters[2].Title) +} + +func TestAudiobookEmbeddedM4BChapters(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio M4B %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + fixtureAudio(t, "chaptered.m4b", filepath.Join(folder, "Chaptered Book.m4b")) + scanTestFolder(t, setup, admin, folder) + + item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Chaptered Book.m4b")) + require.True(t, found) + bookID := uuid.UUID(item.ID.Bytes) + + var data struct { + Chapters []struct { + Title string `json:"title"` + } `json:"chapters"` + DurationSeconds float64 `json:"duration_seconds"` + } + status := getJSONWithAuth(t, setup, "/api/media-items/"+bookID.String()+"/chapters", &data) + require.Equal(t, http.StatusOK, status) + require.Len(t, data.Chapters, 2, "embedded m4b chapters parse") + require.Equal(t, "Part One", data.Chapters[0].Title) + require.Equal(t, "Part Two", data.Chapters[1].Title) + require.Greater(t, data.DurationSeconds, float64(0)) +} + +func TestAudiobookPlaylistOrdering(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio M3U %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + // Three tag-less tracks (natural order 01,02,10) plus a playlist + // imposing a DIFFERENT order and names. + fixtureAudio(t, "plain.mp3", filepath.Join(folder, "Playlist Book", "01_one.mp3")) + fixtureAudio(t, "plain.mp3", filepath.Join(folder, "Playlist Book", "02_two.mp3")) + fixtureAudio(t, "plain.mp3", filepath.Join(folder, "Playlist Book", "10_ten.mp3")) + playlist := "#EXTM3U\n#EXTINF:60,Epilogue\n./10_ten.mp3\n#EXTINF:60,Prologue\n01_one.mp3\n" + require.NoError(t, os.WriteFile(filepath.Join(folder, "Playlist Book", "book.m3u"), []byte(playlist), 0o644)) + scanTestFolder(t, setup, admin, folder) + + item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Playlist Book")) + require.True(t, found, "folder book created") + require.NotNil(t, item.AudioMetadata) + var payload struct { + Tracks []struct { + Index int `json:"index"` + Path string `json:"path"` + Title string `json:"title"` + } `json:"tracks"` + } + require.NoError(t, json.Unmarshal(item.AudioMetadata, &payload)) + require.Len(t, payload.Tracks, 3) + require.Equal(t, "Playlist Book/10_ten.mp3", payload.Tracks[0].Path, "playlist order beats natural sort") + require.Equal(t, "Epilogue", payload.Tracks[0].Title) + require.Equal(t, "Playlist Book/01_one.mp3", payload.Tracks[1].Path) + require.Equal(t, "Prologue", payload.Tracks[1].Title) + require.Equal(t, "Playlist Book/02_two.mp3", payload.Tracks[2].Path, "unlisted track appended in natural order") +} + +func TestAudiobookEmbeddedCoverArt(t *testing.T) { + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + admin, _ := moveTestAdminID(t, setup) + libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Cover %d", time.Now().UnixNano())) + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + // Single file: embedded APIC → ".cover.jpg" sibling. + fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Cover Book", "tagged.mp3")) + // Folder book: sidecar cover.jpg must WIN over the embedded art. + fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Sidecar Book", "01 track.mp3")) + fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Sidecar Book", "02 track.mp3")) + // A real (if tiny) sidecar cover next to the folder book: + fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Sidecar Book", "cover.jpg")) + scanTestFolder(t, setup, admin, folder) + + single, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Cover Book/tagged.mp3")) + require.True(t, found) + if !ffmpegAvailableForTest() { + t.Skip("ffmpeg unavailable in this environment; embedded covers degrade gracefully") + } + require.NotEmpty(t, single.CoverImagePath.String, "embedded APIC becomes the cover") + coverRel := single.CoverImagePath.String + _, err := os.Stat(filepath.Join(folder, coverRel)) + require.NoError(t, err, "cover sibling file exists on disk") + + folder_, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Sidecar Book")) + require.True(t, found) + require.Equal(t, filepath.ToSlash("Sidecar Book/cover.jpg"), folder_.CoverImagePath.String, + "sidecar cover wins over embedded art") +} + +func ffmpegAvailableForTest() bool { + _, err := os.Stat("/usr/bin/ffmpeg") + if err == nil { + return true + } + _, err = exec.LookPath("ffmpeg") + return err == nil +} + +func TestRescanKeepsFilenameTitle(t *testing.T) { + // Regression pin: extractMetadata's default branch (.mobi/.azw/.pdb + // and unknown types) no longer pre-fills the filename as title, so + // the rescan path MUST apply the final filename fallback itself — + // a sidecar-less, tag-less book at a library root keeps its title + // instead of being blanked by a rescan. + setup := setupTestServer(t) + t.Cleanup(func() { _ = setup.Close() }) + admin, _ := moveTestAdminID(t, setup) + libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Rescan Title %d", time.Now().UnixNano()), "ebooks") + t.Cleanup(func() { deleteLibraries(t, setup, libID) }) + + // Library-root .mobi with no sidecars, no tags, no folder structure. + require.NoError(t, os.WriteFile(filepath.Join(folder, "Plain Book.mobi"), []byte("dummy mobi bytes"), 0o644)) + scanTestFolder(t, setup, admin, folder) + + item, found := findAudioItemByPath(t, setup, libID, "Plain Book.mobi") + require.True(t, found) + require.Equal(t, "Plain Book", item.Title, "create path falls back to filename") + + bookID := uuid.UUID(item.ID.Bytes) + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+bookID.String()+"/rescan", nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + rescanned, err := setup.DB.GetMediaItem(context.Background(), item.ID) + require.NoError(t, err) + require.Equal(t, "Plain Book", rescanned.Title, "rescan must not blank the filename-derived title") +} diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 4cb8e799..423afd90 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -244,8 +244,12 @@ CREATE TABLE IF NOT EXISTS media_items ( -- { "narrators": [], "duration_seconds": N, "codec_profile": "LC", -- "bit_rate": N, "asin": "...", "chapters": [ -- { "index": 0, "title": "...", "start": 0.0, "end": 168.0 } ] } - -- The chapters array IS the format abstraction: embedded m4b chapters - -- and (later) folder-as-book files both write this exact shape. + -- The chapters array IS the format abstraction: embedded m4b chapters, + -- folder-of-tracks books and zip-of-audio books all write this exact + -- shape. Multi-file books add additive keys: "layout" + -- ("folder" | "zip"; absent = single file) and "tracks" (one entry + -- per playable file with global-seconds start/end plus the path the + -- /audio/:trackIndex endpoint resolves). audio_metadata JSONB ); diff --git a/docs/contract_pins.md b/docs/contract_pins.md index 727d8a82..421e857a 100644 --- a/docs/contract_pins.md +++ b/docs/contract_pins.md @@ -102,3 +102,20 @@ cover_image_path, has_conflict, format_group, badge`. origin — its rows are unchanged. Request-parse widening documented per rule 2; response shapes untouched; locked by TestOriginWriterAttribution. + +## Changelog entry — multi-file audiobooks (2026-10-18) + +- `GET /api/media-items/:id/chapters` gained ADDITIVE response keys + `layout` ("" for single-file books, `"folder"` or `"zip"` for + multi-file books) and `tracks[]` (`index, url, title, path, start, + end, duration_seconds, size_bytes`; empty for single-file books). + Existing keys (`media_item_id, chapters, duration_seconds, + codec_profile`) unchanged — no pin existed on this endpoint, so no + pin widened; recorded here per rule 2's spirit. +- New sibling endpoint `GET /api/media-items/:id/audio/:trackIndex` + streams one track of a multi-file book (Range-capable). New surface, + not a modification; gated by library visibility like `ServeFile` + (403 on hidden libraries). +- `media_items.audio_metadata` payload gained additive JSON keys + `layout` and `tracks` (see schema.sql comment). Consumers parse + additively; stored single-file payloads are unchanged. diff --git a/docs/developer/api/media-items/get_audio_chapters.md b/docs/developer/api/media-items/get_audio_chapters.md new file mode 100644 index 00000000..a63e2d05 --- /dev/null +++ b/docs/developer/api/media-items/get_audio_chapters.md @@ -0,0 +1,53 @@ +# Get Audio Chapters + +The format-abstraction chapter list for audiobooks, sourced from +`media_items.audio_metadata`. Embedded m4b chapters, folder-of-tracks +books and zip-of-audio books all write the same chapter shape; the +locator `{chapter, audio_offset_seconds}` and this endpoint never learn +which file model produced them. + +**Endpoint**: `GET /api/media-items/{id}/chapters` +**Auth**: Required (JWT) + +## Response + +```json +{ + "media_item_id": "…", + "chapters": [ + { "index": 0, "title": "Chapter One", "start": 0.0, "end": 168.0 } + ], + "duration_seconds": 36000.0, + "codec_profile": "mp3", + "layout": "folder", + "tracks": [ + { + "index": 0, + "url": "/api/media-items/{id}/audio/0", + "title": "01 First", + "path": "Author/Title/01 First.mp3", + "start": 0.0, + "end": 168.0, + "duration_seconds": 168.0, + "size_bytes": 2700000 + } + ] +} +``` + +- `layout` is `""` for single-file books (the Batch A model — the whole + file streams through the `/uploads` path), `"folder"` for a directory + of track files and `"zip"` for a zip-of-audio book. +- `tracks` is empty for single-file books and carries one entry per + playable file otherwise. `start`/`end` are cumulative GLOBAL seconds — + the same coordinate system chapters, bookmarks and + `audio_offset_seconds` use. + +## Error Responses + +| Code | Description | +| ---- | ------------------------------- | +| 400 | Invalid media item id | +| 401 | Missing/invalid token | +| 404 | Book not found | +| 500 | audio_metadata failed to parse | diff --git a/docs/developer/api/media-items/get_audio_track.md b/docs/developer/api/media-items/get_audio_track.md new file mode 100644 index 00000000..11d72e7c --- /dev/null +++ b/docs/developer/api/media-items/get_audio_track.md @@ -0,0 +1,44 @@ +# Get Audio Track + +Streams one playable track of a multi-file audiobook (folder-of-tracks +or zip-of-audio book). Track indexes and bounds come from the track +manifest on [Get Audio Chapters](get_audio_chapters.md). + +**Endpoint**: `GET /api/media-items/{id}/audio/{trackIndex}` +**Auth**: Required (JWT — Bearer header or session cookie; the web +player's `