feat(audiobooks): multi-file books (folder-of-tracks + zip) + exhaustive metadata ingestion
Multi-file book ingest: a directory of ≥2 audio files (recursively —
disc-part folders like CD1/Disc 2 merge upward, siblings never merge)
ingests as ONE book whose file_path is the book-root directory; a zip
of audio tracks is its own book (file_path = the archive). Both write
the documented chapters abstraction {index,title,start,end} into
audio_metadata and add additive keys: layout ("folder"|"zip") and a
tracks[] manifest (per-track path, global-seconds start/end, duration,
size). Pre-grouping one-track strays under a book root are absorbed on
rescan (annotations cascade). Rescans take a stat-only fast path
(track count + total size + known durations unchanged → no re-probe —
a 1189-track book must not re-ffprobe every auto-scan); a zero-duration
book counts as changed so a ffprobe-equipped rescan fills durations in.
Folder-book roots count as present in the archive lifecycle pass, and
per-item rescan (RescanMediaItem) routes multi-file books through a
book-level refresh — the generic updater would read the root/zip as if
it were the book file and clobber format_group with "unknown".
Serving: GET /api/media-items/:id/chapters gains additive layout +
tracks keys (urls → the new endpoint). New GET
/api/media-items/:id/audio/:trackIndex streams one track — folder
tracks served from the real file (full Range via ServeContent), zip
tracks via a reopen-and-skip ReadSeeker over the entry (deflate has no
random access; acceptable at audio bitrates; stored entries stay
section readers). Both endpoints gate on library visibility (403 on
hidden libraries — family-lens rule). ServeFile and OPDS DownloadBook
refuse directory paths (folder books have no single-file download).
Web player (next commit) consumes the manifest; progress/bookmarks/
deep-links keep their global-seconds coordinates, so the server-side
progress model is untouched. sync learns FormatGroupAudiobook
(percentage passthrough in ConvertProgress); detectFormatType stops
reporting "unknown" for audio.
Metadata ingestion (Batch B) — read everything available, degrade
gracefully, documented precedence sidecars → folder structure →
embedded tags → filename: ffprobe tags are now a case-insensitive
lookup map (freeform keys pass through verbatim); composer fills
narrators (Audible's own convention, kept as ONE string — no reliable
multi-narrator delimiter); grouping + Audible freeform atoms
(SERIES/SERIES-PART/SUBTITLE/ASIN/ABRIDGED) fill series/index/
subtitle-join/ASIN/abridged (explicit sidecar abridged-false is
guarded); date/genre fall back to tags; album/album_artist/artist
fill title/author. Genre persists at create for all formats.
Embedded cover art (mp3 APIC, m4b covr, flac picture) extracts via one
optional ffmpeg call as a "<book>.cover.jpg" sibling — sidecar covers
win. CUE sheets (FILE-matched, INDEX 01 at 75fps) are the lowest-
priority chapter source under sidecars and embedded m4b chapters. A
lone m3u/m3u8 in a book root imposes playlist ordering + #EXTINF
names; (disc, track) tags reorder tracks when coverage is complete.
extractMetadata's default branch no longer pre-fills the filename as
title (tags must outrank it) — final filename fallbacks now exist on
BOTH the create and rescan paths (TestRescanKeepsFilenameTitle pins
that rescans cannot blank a title); tagless zip books take the zip's
own name, not their parent folder's.
Accepted containers widen to .m4b .m4a .mp3 .flac .zip .opus .ogg .oga
.aac .wav with real media types registered (audio/ogg per RFC 7845 for
Opus-in-Ogg) — all browser-playable; .wma deliberately excluded.
Tests: unit tests for CUE parsing, playlist hints, tag fallbacks
(synthetic ffprobe reports — ffmpeg cannot write Audible freeform
atoms), ordinal parsing, tag ordering, natural sort, grouping rules;
integration suite (11 tests) over the live dev DB: folder/zip ingest +
idempotency, stray absorption, chapters manifest, per-track streaming
with Range, hidden-library 403s, embedded covers + sidecar-wins, CUE
chapters, embedded m4b chapters, playlist ordering, tag fallbacks, all
five new containers with content-type assertions, rescan title pin.
Fixture media is real committed-format binary content living untracked
under internal/services/testdata/ (gitignored by policy; recipes in
docs/contributing/development.md → Media fixtures). Test image now
ships ffmpeg to match the runtime's audio toolchain.
This commit is contained in:
1 parent
24228e7147
commit
6216bd9bcf
26 files changed
+3419
-60
No files matched your search
@@ -0,0 +1,571 @@
|
||||
package main
|
||||
|
||||
// Multi-file audiobook integration tests: folder-of-tracks and zip-of-
|
||||
// audio books must ingest as ONE item each (never per-track items), the
|
||||
// /chapters manifest must expose the track list, the per-track streaming
|
||||
// endpoint must serve bytes behind the library-visibility gate, and
|
||||
// rescans must be idempotent while absorbing pre-grouping one-track
|
||||
// strays. Live dev DB — all fixtures use test-named libraries under
|
||||
// t.TempDir() folders and cascade away with the library delete.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// writeTestTrack renders a short silent mp3 via ffmpeg when available so
|
||||
// ffprobe can produce real durations; otherwise falls back to dummy bytes
|
||||
// (ingest is ffprobe-optional by design — structure assertions don't need
|
||||
// real audio).
|
||||
func writeTestTrack(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
|
||||
"-i", "sine=frequency=440:duration=0.3", "-codec:a", "libmp3lame", "-b:a", "32k", path)
|
||||
if out, err := cmd.CombinedOutput(); err == nil {
|
||||
return
|
||||
} else {
|
||||
t.Logf("ffmpeg track render failed (%s), falling back to dummy bytes: %s", err, out)
|
||||
}
|
||||
}
|
||||
require.NoError(t, os.WriteFile(path, []byte("ID3 dummy audio"), 0o644))
|
||||
}
|
||||
|
||||
// audioTestLibrary builds an audiobooks-type library with a fresh temp
|
||||
// folder, returning its UUID and the folder path.
|
||||
func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) {
|
||||
t.Helper()
|
||||
libID, folder := createTypedTestLibrary(t, setup, name, "audiobooks")
|
||||
return libID, folder
|
||||
}
|
||||
|
||||
// listLibraryAudioItems returns every media item row of the library.
|
||||
func listLibraryAudioItems(t *testing.T, setup *TestServerSetup, libID pgtype.UUID) []database.ListMediaItemsByLibraryIncludingArchivedRow {
|
||||
t.Helper()
|
||||
items, err := setup.DB.ListMediaItemsByLibraryIncludingArchived(context.Background(), libID)
|
||||
require.NoError(t, err)
|
||||
return items
|
||||
}
|
||||
|
||||
// findAudioItemByPath returns the item with the given file_path.
|
||||
func findAudioItemByPath(t *testing.T, setup *TestServerSetup, libID pgtype.UUID, filePath string) (database.ListMediaItemsByLibraryIncludingArchivedRow, bool) {
|
||||
t.Helper()
|
||||
for _, item := range listLibraryAudioItems(t, setup, libID) {
|
||||
if item.FilePath == filePath {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return database.ListMediaItemsByLibraryIncludingArchivedRow{}, false
|
||||
}
|
||||
|
||||
// decodeAudioPayload parses the audio_metadata JSONB of an item.
|
||||
func decodeAudioPayload(t *testing.T, item database.ListMediaItemsByLibraryIncludingArchivedRow) (layout string, trackCount, chapterCount int, duration float64) {
|
||||
t.Helper()
|
||||
require.NotNil(t, item.AudioMetadata, "audio_metadata must be present")
|
||||
var payload struct {
|
||||
Layout string `json:"layout"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
Tracks []struct {
|
||||
Index int `json:"index"`
|
||||
Path string `json:"path"`
|
||||
} `json:"tracks"`
|
||||
Chapters []json.RawMessage `json:"chapters"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(item.AudioMetadata, &payload))
|
||||
return payload.Layout, len(payload.Tracks), len(payload.Chapters), payload.DurationSeconds
|
||||
}
|
||||
|
||||
func TestAudiobookFolderOfTracks(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Folder %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// Author/Title/01.mp3, 02.mp3 — the canonical folder book.
|
||||
writeTestTrack(t, filepath.Join(folder, "Test Author", "Test Title", "02 Second.mp3"))
|
||||
writeTestTrack(t, filepath.Join(folder, "Test Author", "Test Title", "01 First.mp3"))
|
||||
// A lone single-file book in a sibling folder must stay single-file.
|
||||
writeTestTrack(t, filepath.Join(folder, "Test Author", "Lone Book", "lonely.mp3"))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
// The folder is ONE item whose file_path is the directory.
|
||||
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Test Author/Test Title"))
|
||||
require.True(t, found, "folder book item must exist at the directory path")
|
||||
layout, tracks, chapters, duration := decodeAudioPayload(t, book)
|
||||
require.Equal(t, "folder", layout)
|
||||
require.Equal(t, 2, tracks)
|
||||
require.Equal(t, 2, chapters)
|
||||
if duration <= 0 {
|
||||
t.Log("no ffprobe durations available in this environment (degraded ingest)")
|
||||
}
|
||||
|
||||
// The lone mp3 keeps the single-file model.
|
||||
_, loneFound := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Test Author/Lone Book/lonely.mp3"))
|
||||
require.True(t, loneFound, "lone mp3 must stay its own single-file item")
|
||||
require.Len(t, listLibraryAudioItems(t, setup, libID), 2, "exactly two items: one folder book + one single-file book")
|
||||
|
||||
// Rescan idempotency: nothing duplicates, manifest unchanged.
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
require.Len(t, listLibraryAudioItems(t, setup, libID), 2)
|
||||
again, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Test Author/Test Title"))
|
||||
require.True(t, found)
|
||||
require.JSONEq(t, string(book.AudioMetadata), string(again.AudioMetadata))
|
||||
}
|
||||
|
||||
func TestAudiobookAbsorbsOneTrackStrays(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Merge %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// First scan: a single mp3 → single-file item (the pre-grouping model).
|
||||
writeTestTrack(t, filepath.Join(folder, "Merge Book", "01 only.mp3"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
_, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Merge Book/01 only.mp3"))
|
||||
require.True(t, found, "single track ingests as its own item first")
|
||||
|
||||
// Second track arrives; rescan must create the folder book and delete
|
||||
// the stray one-track item.
|
||||
writeTestTrack(t, filepath.Join(folder, "Merge Book", "02 and.mp3"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Merge Book"))
|
||||
require.True(t, found, "folder book must exist after rescan")
|
||||
layout, tracks, _, _ := decodeAudioPayload(t, book)
|
||||
require.Equal(t, "folder", layout)
|
||||
require.Equal(t, 2, tracks)
|
||||
|
||||
items := listLibraryAudioItems(t, setup, libID)
|
||||
require.Len(t, items, 1, "the one-track stray must be absorbed into the folder book")
|
||||
}
|
||||
|
||||
func TestAudiobookZipBook(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Zip %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// Build a zip of mp3s via ffmpeg-rendered tracks.
|
||||
dir := t.TempDir()
|
||||
writeTestTrack(t, filepath.Join(dir, "a.mp3"))
|
||||
writeTestTrack(t, filepath.Join(dir, "b.mp3"))
|
||||
zipPath := filepath.Join(folder, "Zipped Book.zip")
|
||||
zipFiles(t, zipPath, map[string]string{
|
||||
"01 first.mp3": filepath.Join(dir, "a.mp3"),
|
||||
"02 second.mp3": filepath.Join(dir, "b.mp3"),
|
||||
})
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Zipped Book.zip"))
|
||||
require.True(t, found, "zip book item must exist at the zip path")
|
||||
layout, tracks, chapters, _ := decodeAudioPayload(t, book)
|
||||
require.Equal(t, "zip", layout)
|
||||
require.Equal(t, 2, tracks)
|
||||
require.Equal(t, 2, chapters)
|
||||
|
||||
// Rescan idempotency.
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
require.Len(t, listLibraryAudioItems(t, setup, libID), 1)
|
||||
}
|
||||
|
||||
func TestAudiobookTrackEndpoints(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
regularID := getRegularUserID(t, setup.DB)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Serve %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
writeTestTrack(t, filepath.Join(folder, "Serve Book", "01 one.mp3"))
|
||||
writeTestTrack(t, filepath.Join(folder, "Serve Book", "02 two.mp3"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Serve Book"))
|
||||
require.True(t, found)
|
||||
bookID := uuid.UUID(book.ID.Bytes)
|
||||
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("chapters manifest exposes tracks", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/chapters", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var data struct {
|
||||
Layout string `json:"layout"`
|
||||
Tracks []struct {
|
||||
Index int `json:"index"`
|
||||
URL string `json:"url"`
|
||||
Path string `json:"path"`
|
||||
Start float64 `json:"start"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
|
||||
require.Equal(t, "folder", data.Layout)
|
||||
require.Len(t, data.Tracks, 2)
|
||||
require.Equal(t, "/api/media-items/"+bookID.String()+"/audio/0", data.Tracks[0].URL)
|
||||
})
|
||||
|
||||
t.Run("track streams with range support", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio/0", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
req.Header.Set("Range", "bytes=0-99")
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusPartialContent, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("out-of-range track index is 404", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio/9", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("hidden library is 403 on chapters and tracks", func(t *testing.T) {
|
||||
_, err := setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{
|
||||
UserID: pgtype.UUID{Bytes: regularID, Valid: true},
|
||||
LibraryID: libID,
|
||||
IsVisible: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, path := range []string{
|
||||
"/api/media-items/" + bookID.String() + "/chapters",
|
||||
"/api/media-items/" + bookID.String() + "/audio/0",
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusForbidden, resp.StatusCode, "%s must be gated", path)
|
||||
}
|
||||
|
||||
// Restore visibility so harness cleanup ordering never sees a
|
||||
// stale hidden row for a deleted library's user.
|
||||
_, err = setup.DB.SetLibraryVisibility(context.Background(), database.SetLibraryVisibilityParams{
|
||||
UserID: pgtype.UUID{Bytes: regularID, Valid: true},
|
||||
LibraryID: libID,
|
||||
IsVisible: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// zipFiles builds a zip archive at zipPath mapping entry names to source
|
||||
// file paths on disk.
|
||||
func zipFiles(t *testing.T, zipPath string, entries map[string]string) {
|
||||
t.Helper()
|
||||
out, err := os.Create(zipPath)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = out.Close() }()
|
||||
w := zip.NewWriter(out)
|
||||
for name, src := range entries {
|
||||
data, err := os.ReadFile(src)
|
||||
require.NoError(t, err)
|
||||
f, err := w.Create(name)
|
||||
require.NoError(t, err)
|
||||
_, err = f.Write(data)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, w.Close())
|
||||
}
|
||||
|
||||
// fixtureAudio copies a committed binary fixture (real tagged media —
|
||||
// the test container has no way to generate it) into a library folder.
|
||||
// Path is relative from cmd/server/tests (three deep) to the services
|
||||
// testdata tree.
|
||||
func fixtureAudio(t *testing.T, name, dest string) {
|
||||
t.Helper()
|
||||
src := filepath.Join("..", "..", "..", "internal", "services", "testdata", "audio", name)
|
||||
data, err := os.ReadFile(src)
|
||||
require.NoError(t, err, "fixture %s must exist in the repo", name)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(dest), 0o755))
|
||||
require.NoError(t, os.WriteFile(dest, data, 0o644))
|
||||
}
|
||||
|
||||
// getJSONWithAuth fetches an API endpoint with the admin token and
|
||||
// decodes the body into dst.
|
||||
func getJSONWithAuth(t *testing.T, setup *TestServerSetup, path string, dst interface{}) int {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if dst != nil {
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(dst))
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func TestAudiobookNewContainerFormats(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Formats %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
for _, name := range []string{"book.opus", "book.ogg", "book.oga", "book.aac", "book.wav"} {
|
||||
// one book per folder — a directory with ≥2 audio files is ONE
|
||||
// book by the grouping doctrine
|
||||
fixtureAudio(t, name, filepath.Join(folder, "Formats "+name, name))
|
||||
}
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
items := listLibraryAudioItems(t, setup, libID)
|
||||
require.Len(t, items, 5, "each new container ingests as its own single-file book")
|
||||
mimes := map[string]string{}
|
||||
for _, item := range items {
|
||||
require.Equal(t, "audiobook", item.FormatGroup, "%s must classify as audiobook", item.FilePath)
|
||||
mimes[filepath.Ext(item.FilePath)] = item.MimeType.String
|
||||
}
|
||||
require.Equal(t, map[string]string{
|
||||
".opus": "audio/ogg", ".ogg": "audio/ogg", ".oga": "audio/ogg",
|
||||
".aac": "audio/aac", ".wav": "audio/wav",
|
||||
}, mimes)
|
||||
|
||||
// The download endpoint must serve the REAL content types — a wrong
|
||||
// audio/mpeg fallback makes browsers refuse the stream.
|
||||
for _, name := range []string{"book.opus", "book.aac", "book.wav"} {
|
||||
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Formats "+name+"/"+name))
|
||||
require.True(t, found, name)
|
||||
bookID := uuid.UUID(item.ID.Bytes)
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/"+bookID.String()+"/download", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
// Drain but discard: a 0.3s sine is a few KB — nothing to save.
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, item.MimeType.String, resp.Header.Get("Content-Type"), "%s content type", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudiobookTagFallbacks(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Tags %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// No sidecars anywhere: the file's own tags are the only source.
|
||||
fixtureAudio(t, "tagged.mp3", filepath.Join(folder, "Tag Author", "The Tagged Album", "tagged.mp3"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Tag Author/The Tagged Album/tagged.mp3"))
|
||||
require.True(t, found)
|
||||
require.Equal(t, "The Tagged Album", item.Title, "album tag names the book")
|
||||
require.Equal(t, "Tag Author", item.Author.String, "album_artist tag names the author")
|
||||
require.Len(t, item.Narrators, 1, "composer tag fills narrators (Audible convention)")
|
||||
require.Equal(t, "Tag Narrator", item.Narrators[0])
|
||||
require.Equal(t, "Tag Series", item.Series.String, "grouping tag fills series")
|
||||
require.Equal(t, "Audiobook", item.Genre.String, "genre tag fills genre")
|
||||
require.False(t, item.DatePublished.Time.IsZero(), "date tag fills publish date")
|
||||
require.Equal(t, 2020, item.DatePublished.Time.Year())
|
||||
// ASIN/abridged/subtitle arrive only through Audible freeform atoms,
|
||||
// which ffmpeg cannot write into a fixture — those fallbacks are
|
||||
// pinned by TestApplyAudioTagFallbacks (synthetic reports).
|
||||
}
|
||||
|
||||
func TestAudiobookCueSheetChapters(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Cue %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
fixtureAudio(t, "cuebook.flac", filepath.Join(folder, "Cue Book", "cuebook.flac"))
|
||||
fixtureAudio(t, "cuebook.cue", filepath.Join(folder, "Cue Book", "cuebook.cue"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Cue Book/cuebook.flac"))
|
||||
require.True(t, found)
|
||||
bookID := uuid.UUID(item.ID.Bytes)
|
||||
|
||||
var data struct {
|
||||
Chapters []struct {
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title"`
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end"`
|
||||
} `json:"chapters"`
|
||||
}
|
||||
status := getJSONWithAuth(t, setup, "/api/media-items/"+bookID.String()+"/chapters", &data)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Len(t, data.Chapters, 3, "cue tracks become chapters")
|
||||
require.Equal(t, "Opening", data.Chapters[0].Title)
|
||||
require.Equal(t, "The Middle Bit", data.Chapters[1].Title)
|
||||
require.InDelta(t, 20.0, data.Chapters[1].Start, 0.01)
|
||||
require.Equal(t, "Closing", data.Chapters[2].Title)
|
||||
}
|
||||
|
||||
func TestAudiobookEmbeddedM4BChapters(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio M4B %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
fixtureAudio(t, "chaptered.m4b", filepath.Join(folder, "Chaptered Book.m4b"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Chaptered Book.m4b"))
|
||||
require.True(t, found)
|
||||
bookID := uuid.UUID(item.ID.Bytes)
|
||||
|
||||
var data struct {
|
||||
Chapters []struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"chapters"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
}
|
||||
status := getJSONWithAuth(t, setup, "/api/media-items/"+bookID.String()+"/chapters", &data)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Len(t, data.Chapters, 2, "embedded m4b chapters parse")
|
||||
require.Equal(t, "Part One", data.Chapters[0].Title)
|
||||
require.Equal(t, "Part Two", data.Chapters[1].Title)
|
||||
require.Greater(t, data.DurationSeconds, float64(0))
|
||||
}
|
||||
|
||||
func TestAudiobookPlaylistOrdering(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio M3U %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// Three tag-less tracks (natural order 01,02,10) plus a playlist
|
||||
// imposing a DIFFERENT order and names.
|
||||
fixtureAudio(t, "plain.mp3", filepath.Join(folder, "Playlist Book", "01_one.mp3"))
|
||||
fixtureAudio(t, "plain.mp3", filepath.Join(folder, "Playlist Book", "02_two.mp3"))
|
||||
fixtureAudio(t, "plain.mp3", filepath.Join(folder, "Playlist Book", "10_ten.mp3"))
|
||||
playlist := "#EXTM3U\n#EXTINF:60,Epilogue\n./10_ten.mp3\n#EXTINF:60,Prologue\n01_one.mp3\n"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(folder, "Playlist Book", "book.m3u"), []byte(playlist), 0o644))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Playlist Book"))
|
||||
require.True(t, found, "folder book created")
|
||||
require.NotNil(t, item.AudioMetadata)
|
||||
var payload struct {
|
||||
Tracks []struct {
|
||||
Index int `json:"index"`
|
||||
Path string `json:"path"`
|
||||
Title string `json:"title"`
|
||||
} `json:"tracks"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(item.AudioMetadata, &payload))
|
||||
require.Len(t, payload.Tracks, 3)
|
||||
require.Equal(t, "Playlist Book/10_ten.mp3", payload.Tracks[0].Path, "playlist order beats natural sort")
|
||||
require.Equal(t, "Epilogue", payload.Tracks[0].Title)
|
||||
require.Equal(t, "Playlist Book/01_one.mp3", payload.Tracks[1].Path)
|
||||
require.Equal(t, "Prologue", payload.Tracks[1].Title)
|
||||
require.Equal(t, "Playlist Book/02_two.mp3", payload.Tracks[2].Path, "unlisted track appended in natural order")
|
||||
}
|
||||
|
||||
func TestAudiobookEmbeddedCoverArt(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Cover %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// Single file: embedded APIC → "<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")
|
||||
}
|
||||
Reference in new issue
Block a user