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 `