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") } // TestAudiobookFolderSidecarEditDetected is the Phase 6 headline: a // size-stable folder book is changed by EDITING its metadata.json — // the track-count/size fingerprint never saw it, not even on force // rescan. Pins the ladder end to end: seeding at create, pure-skip // steady state, same-size sidecar edit consumed (title + collection // sync), and the refreshed manifest. func TestAudiobookFolderSidecarEditDetected(t *testing.T) { setup := setupTestServer(t) t.Cleanup(func() { _ = setup.Close() }) admin, _ := moveTestAdminID(t, setup) libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Sidecar %d", time.Now().UnixNano())) t.Cleanup(func() { deleteLibraries(t, setup, libID) }) bookDir := filepath.Join(folder, "Sidecar Author", "Sidecar Dir Name") writeTestTrack(t, filepath.Join(bookDir, "01 first.mp3")) writeTestTrack(t, filepath.Join(bookDir, "02 second.mp3")) sidecar := filepath.Join(bookDir, "metadata.json") // Sidecar title deliberately differs from the directory name so the // assertion proves the SIDECAR won. require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Title"}`), 0o644)) scanTestFolder(t, setup, admin, folder) book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Sidecar Author/Sidecar Dir Name")) require.True(t, found) require.Equal(t, "Sidecar Real Title", book.Title, "sidecar title wins at create") require.NotEqual(t, book.UpdatedAt.Time, time.Time{}, "sanity") // Manifest seeded at create (the multifile create tail). state, err := setup.DB.GetMediaScanState(context.Background(), book.ID) require.NoError(t, err) require.NotEmpty(t, state.Manifest, "folder create must seed the sidecar manifest") seededManifest := state.Manifest // Steady state: the manifest ladder runs and skips — pure skip. scanTestFolder(t, setup, admin, folder) afterSkip := findItemUpdated(t, setup, libID, book.FilePath) require.Equal(t, book.UpdatedAt.Time, afterSkip, "unchanged rescan with seeded manifest is a pure skip") // Same-size sidecar edit — the size-only blind spot. sameSize := []byte(`{"title":"Sidecar Real Titlz"}`) require.Len(t, sameSize, len(`{"title":"Sidecar Real Title"}`), "fixture must be a same-size edit") require.NoError(t, os.WriteFile(sidecar, sameSize, 0o644)) scanTestFolder(t, setup, admin, folder) edited := findAudioItemByPathRow(t, setup, libID, book.FilePath) require.Equal(t, "Sidecar Real Titlz", edited.Title, "same-size sidecar edit is detected and consumed") state, err = setup.DB.GetMediaScanState(context.Background(), edited.ID) require.NoError(t, err) require.NotEmpty(t, state.Manifest) require.NotEqual(t, string(seededManifest), string(state.Manifest), "change path stores a fresh full manifest") // Playlist sync on the change path (plan Correction 2): a reading- // order entry added to an EXISTING book creates its collection, and // a further rescan does not duplicate it. require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Titlz","series":["Sidecar Saga (Reading Order) #1"]}`), 0o644)) scanTestFolder(t, setup, admin, folder) var collCount int require.NoError(t, setup.DBPool.QueryRow(context.Background(), `SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin, ).Scan(&collCount)) require.Equal(t, 1, collCount, "reading-order sidecar edit creates the collection for an existing book") scanTestFolder(t, setup, admin, folder) require.NoError(t, setup.DBPool.QueryRow(context.Background(), `SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin, ).Scan(&collCount)) require.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection") } // TestAudiobookTrackOnlyChangeKeepsEditorial pins the other half of the // contract: a TRACK change (sidecars untouched) keeps the existing // scan-derived-only update — editorial columns stay as the sidecar // wrote them, and no editorial apply fires. func TestAudiobookTrackOnlyChangeKeepsEditorial(t *testing.T) { setup := setupTestServer(t) t.Cleanup(func() { _ = setup.Close() }) admin, _ := moveTestAdminID(t, setup) libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio TrackOnly %d", time.Now().UnixNano())) t.Cleanup(func() { deleteLibraries(t, setup, libID) }) bookDir := filepath.Join(folder, "Track Only Author", "Track Only Book") writeTestTrack(t, filepath.Join(bookDir, "01 first.mp3")) writeTestTrack(t, filepath.Join(bookDir, "02 second.mp3")) require.NoError(t, os.WriteFile(filepath.Join(bookDir, "metadata.json"), []byte(`{"title":"Editorial Anchor"}`), 0o644)) scanTestFolder(t, setup, admin, folder) book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Track Only Author/Track Only Book")) require.True(t, found) require.Equal(t, "Editorial Anchor", book.Title) // A third track arrives; sidecars untouched → track-count change → // payload update only. writeTestTrack(t, filepath.Join(bookDir, "03 third.mp3")) scanTestFolder(t, setup, admin, folder) updated := findAudioItemByPathRow(t, setup, libID, book.FilePath) layout, tracks, _, _ := decodeAudioPayload(t, updated) require.Equal(t, "folder", layout) require.Equal(t, 3, tracks, "track change takes the payload update") require.Equal(t, "Editorial Anchor", updated.Title, "track-only change must not touch editorial columns") } // TestAudiobookZipSidecarEditAndRestore covers the zip twin: sibling // metadata.json edits are detected, and the fast path lifts archive // state like the folder path always did (Phase 6 parity fix). func TestAudiobookZipSidecarEditAndRestore(t *testing.T) { setup := setupTestServer(t) t.Cleanup(func() { _ = setup.Close() }) admin, _ := moveTestAdminID(t, setup) libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio ZipSidecar %d", time.Now().UnixNano())) t.Cleanup(func() { deleteLibraries(t, setup, libID) }) dir := t.TempDir() writeTestTrack(t, filepath.Join(dir, "a.mp3")) writeTestTrack(t, filepath.Join(dir, "b.mp3")) zipPath := filepath.Join(folder, "Zip Sidecar Book.zip") zipFiles(t, zipPath, map[string]string{ "01 first.mp3": filepath.Join(dir, "a.mp3"), "02 second.mp3": filepath.Join(dir, "b.mp3"), }) sidecar := filepath.Join(folder, "metadata.json") require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Zip Sidecar Title"}`), 0o644)) scanTestFolder(t, setup, admin, folder) book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Zip Sidecar Book.zip")) require.True(t, found) require.Equal(t, "Zip Sidecar Title", book.Title, "sibling sidecar feeds the zip book at create") state, err := setup.DB.GetMediaScanState(context.Background(), book.ID) require.NoError(t, err) require.NotEmpty(t, state.Manifest, "zip create must seed the sidecar manifest") scanTestFolder(t, setup, admin, folder) require.Equal(t, book.UpdatedAt.Time, findItemUpdated(t, setup, libID, book.FilePath), "unchanged zip rescan is a pure skip") require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Zip Sidecar Titlz"}`), 0o644)) scanTestFolder(t, setup, admin, folder) edited := findAudioItemByPathRow(t, setup, libID, book.FilePath) require.Equal(t, "Zip Sidecar Titlz", edited.Title, "zip sidecar edit is detected and consumed") // Archive the row the way the missing-scan does; the file is back, // so the next scan must lift the archive (parity with folders). require.NoError(t, setup.DBPool.QueryRow(context.Background(), `UPDATE media_items SET archived_at = NOW() WHERE id = $1 RETURNING id`, edited.ID, ).Scan(new(pgtype.UUID))) scanTestFolder(t, setup, admin, folder) restored := findAudioItemByPathRow(t, setup, libID, book.FilePath) require.False(t, restored.ArchivedAt.Valid, "zip fast path must lift archive state when the file is back") require.Equal(t, "Zip Sidecar Titlz", restored.Title) } // TestAudiobookSidecarRemovalFallsBackToFilename is the wipe-guard's // testable sibling (faceted plan Phase 6 audit find): a single-file // book whose sidecar is REMOVED must re-derive metadata through the // create-equivalent chain (tags → filename), never blank the title. // The pre-fix change path passed a non-nil empty metadata, which // skipped the filename guard and the tag re-derivation. func TestAudiobookSidecarRemovalFallsBackToFilename(t *testing.T) { setup := setupTestServer(t) t.Cleanup(func() { _ = setup.Close() }) admin, _ := moveTestAdminID(t, setup) libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Removal %d", time.Now().UnixNano())) t.Cleanup(func() { deleteLibraries(t, setup, libID) }) trackPath := filepath.Join(folder, "Fallback Book.mp3") writeTestTrack(t, trackPath) sidecar := filepath.Join(folder, "metadata.json") require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Soon Gone Title"}`), 0o644)) scanTestFolder(t, setup, admin, folder) book, found := findAudioItemByPath(t, setup, libID, "Fallback Book.mp3") require.True(t, found) require.Equal(t, "Soon Gone Title", book.Title, "sidecar title wins at create") // The sidecar vanishes: every sidecar gone → change → re-extract // finds nothing → title falls back to the filename, not blank. require.NoError(t, os.Remove(sidecar)) scanTestFolder(t, setup, admin, folder) after := findAudioItemByPathRow(t, setup, libID, book.FilePath) require.Equal(t, "Fallback Book", after.Title, "sidecar removal re-derives through the create-equivalent chain") } // findItemUpdated reads a media item's updated_at by file_path. func findItemUpdated(t *testing.T, setup *TestServerSetup, libID pgtype.UUID, filePath string) time.Time { t.Helper() for _, item := range listLibraryAudioItems(t, setup, libID) { if item.FilePath == filePath { return item.UpdatedAt.Time } } t.Fatalf("item %q not found in library", filePath) return time.Time{} } // findAudioItemByPathRow is findAudioItemByPath with a hard failure. func findAudioItemByPathRow(t *testing.T, setup *TestServerSetup, libID pgtype.UUID, filePath string) database.ListMediaItemsByLibraryIncludingArchivedRow { t.Helper() item, found := findAudioItemByPath(t, setup, libID, filePath) require.True(t, found, "item %q must exist", filePath) return item }