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.
782 lines
34 KiB
Go
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
|
|
}
|