Files
bookhoard/cmd/server/tests/audiobook_multifile_test.go
T
John O'Keefe 2c5ad014c0 feat(scanner): multifile folder/zip sidecar detection (faceted plan Phase 6)
Reverses the Phase 4 scope cut: for EXISTING folder/zip audiobooks a
sidecar edit was invisible to everything, including force rescan —
updateAudioBookItem writes only scan-derived fields, and these formats
often have no useful embedded tags, so the sidecar is the only source.

- Change fingerprint gains the manifest compare in both processors:
  changed := size differs || track payload differs || manifest drifted.
  The ladder runs against the bulk-loaded map entry (stat pairs only,
  zero extra queries); NULL stored manifest backfills without
  re-extraction (deploy-day rule, same as single-file).
- Manifest-changed applies the once-extracted book metadata to the
  editorial columns (updateMediaItemWithMetadata, overrides protected)
  and re-runs the playlist hook. The apply runs BEFORE the focused
  payload write: the generic update's format-group pass derives mime
  from the sentinel (none) and chapters from metadata.ChapterCount
  (unset) — the payload write must win (rescanMultiFileAudioBook's
  ordering). Track-only changes keep the existing editorial-keep
  update; force rescan keeps it too.
- Manifests seeded at multifile create (folder names derive from the
  sentinel — the exact representative the extractor reads — via
  detectSidecarChangeWithRepresentative, so manifest and extractor can
  never disagree; a directory-basename cover is ignored by the
  extractor and therefore not tracked).
- Zip parity fixes: the fast path now lifts archive state like the
  folder path always did (a returned zip book stayed archived
  forever), and the change branch handles the unchanged-archived case.
- Single-file sidecar change path: extract-failure guard — the old
  non-nil empty &MediaMetadata{} fallback bypassed the update path's
  filename-title guard (preExtracted == nil only) and would blank the
  title on a failed extraction; the row is now left untouched and the
  manifest refreshed. Empty-success extraction (sidecar removed from a
  tag-less book) now passes nil so the create-equivalent chain (tags →
  filename) re-derives instead of blanking.
- Multifile changes now count in the scan summary (changedItems);
  they were previously uncounted.

Tests: sentinel-name derivation unit pin; folder sidecar edit
(title + collection sync + fresh manifest + steady-state skip),
track-only change keeps editorial, zip sidecar edit + archive
restore, sidecar removal falls back to filename (the wipe-guard's
testable sibling). Full container suite: green except the documented
pre-existing TestAdminCreateUser red.
2026-10-19 22:33:01 -04:00

782 lines
34 KiB
Go

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 → "<file>.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
}