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:
John O'Keefe committed 2026-10-19 07:54:28 -04:00
1 parent 24228e7147
commit 6216bd9bcf
26 files changed
+3419 -60

No files matched your search

+5
View File
@@ -574,6 +574,11 @@ type Querier interface {
UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error)
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
// Focused refresh for multi-file audiobooks (folder-of-tracks / zip
// books): rescans recompute the track manifest and the aggregate facts
// that follow from it. Editorial fields stay untouched — admins may have
// edited title/narrators after ingest.
UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (MediaItems, error)
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
// Update media item format
UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error)
+105
View File
@@ -14297,6 +14297,111 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
return i, err
}
const UpdateMediaItemAudioBookPayload = `-- name: UpdateMediaItemAudioBookPayload :one
UPDATE media_items
SET file_size = $2,
mime_type = $3,
audio_metadata = $4,
chapter_count = $5,
updated_at = NOW()
WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence
`
type UpdateMediaItemAudioBookPayloadParams struct {
ID pgtype.UUID `db:"id" json:"id"`
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
ChapterCount pgtype.Int4 `db:"chapter_count" json:"chapter_count"`
}
// Focused refresh for multi-file audiobooks (folder-of-tracks / zip
// books): rescans recompute the track manifest and the aggregate facts
// that follow from it. Editorial fields stay untouched — admins may have
// edited title/narrators after ingest.
func (q *Queries) UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (MediaItems, error) {
row := q.db.QueryRow(ctx, UpdateMediaItemAudioBookPayload,
arg.ID,
arg.FileSize,
arg.MimeType,
arg.AudioMetadata,
arg.ChapterCount,
)
var i MediaItems
err := row.Scan(
&i.ID,
&i.LibraryID,
&i.Title,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Narrators,
&i.Abridged,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
&i.Language,
&i.Edition,
&i.PageCount,
&i.Genre,
&i.CopyrightYear,
&i.GoodreadsID,
&i.OpenlibraryID,
&i.GoogleBooksID,
&i.AddedByAdminID,
&i.CreatedAt,
&i.ImportedAt,
&i.UpdatedAt,
&i.FormatGroup,
&i.FormatMimetype,
&i.IsReflowable,
&i.HasFixedLayout,
&i.TotalCharacters,
&i.ChapterCount,
&i.EntitlementID,
&i.RevisionNumber,
&i.KoboContentID,
&i.KoboMetadata,
&i.MangaType,
&i.ReadingDirection,
&i.SeriesCount,
&i.Volume,
&i.Imprint,
&i.AgeRating,
&i.WebUrl,
&i.StoryArc,
&i.IsBlackAndWhite,
&i.MetadataNotes,
&i.CommunityRating,
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch,
&i.ContributorsSearch,
&i.NarratorsSearch,
&i.FileSha256,
&i.OpfIdentifier,
&i.OpfUuid,
&i.HashConfidence,
)
return i, err
}
const UpdateMediaItemChapterMetadata = `-- name: UpdateMediaItemChapterMetadata :one
UPDATE media_items
SET chapter_metadata = $2, updated_at = NOW()
+14
View File
@@ -3229,3 +3229,17 @@ WHERE mi.archived_at IS NULL
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
ORDER BY mi.file_size DESC, mi.title ASC
LIMIT 5;
-- name: UpdateMediaItemAudioBookPayload :one
-- Focused refresh for multi-file audiobooks (folder-of-tracks / zip
-- books): rescans recompute the track manifest and the aggregate facts
-- that follow from it. Editorial fields stay untouched — admins may have
-- edited title/narrators after ingest.
UPDATE media_items
SET file_size = $2,
mime_type = $3,
audio_metadata = $4,
chapter_count = $5,
updated_at = NOW()
WHERE id = $1
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence;
+231 -4
View File
@@ -1,6 +1,7 @@
package handlers
import (
"archive/zip"
"bookhoard/internal/database"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
@@ -2550,9 +2551,16 @@ func (mh *MediaHandler) ServeFile(c *echo.Context) error {
}
// Check if file exists
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
fullInfo, err := os.Stat(fullPath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
}
// Folder-of-tracks audiobooks store their book root DIRECTORY in
// file_path; there is no single file to serve and ServeFile must not
// fall through to directory listing behavior.
if fullInfo.IsDir() {
return c.JSON(http.StatusNotFound, map[string]string{"error": "multi-file books have no single download"})
}
ext := strings.ToLower(filepath.Ext(fullPath))
contentType := services.MimeTypes[ext]
@@ -2648,9 +2656,12 @@ func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file
// GetAudioChapters handles GET /api/media-items/:id/chapters — the
// format-abstraction chapter list for audiobooks (media_items
// .audio_metadata). Embedded m4b chapters and folder-as-book files write
// the same shape. Empty list when the item isn't audio or hasn't been
// probed.
// .audio_metadata). Embedded m4b chapters, folder-of-tracks books and zip
// books write the same shape. Multi-file books additionally carry
// `layout` and a `tracks` manifest whose URLs point at the per-track
// streaming endpoint; single-file books keep the empty layout their
// existing consumers expect. Empty list when the item isn't audio or
// hasn't been probed.
func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
@@ -2662,6 +2673,20 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}
if !item.LibraryID.Valid {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book has no library"})
}
// Library visibility gate (family-lens rule): chapter lists are media
// metadata — a hidden library must not leak them any more than its
// file bytes.
visibleSet, _, err := mh.visibleLibrarySet(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
}
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
var payload services.AudioMetadataPayload
if len(item.AudioMetadata) > 0 {
@@ -2670,10 +2695,212 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
}
}
tracks := make([]map[string]interface{}, 0, len(payload.Tracks))
for _, tr := range payload.Tracks {
tracks = append(tracks, map[string]interface{}{
"index": tr.Index,
"url": fmt.Sprintf("/api/media-items/%s/audio/%d", mediaID, tr.Index),
"title": tr.Title,
"path": tr.Path,
"start": tr.Start,
"end": tr.End,
"duration_seconds": tr.DurationSeconds,
"size_bytes": tr.SizeBytes,
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": item.ID,
"chapters": payload.Chapters,
"duration_seconds": payload.DurationSeconds,
"codec_profile": payload.CodecProfile,
"layout": payload.Layout,
"tracks": tracks,
})
}
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
// per-track byte stream for multi-file audiobooks (folder books serve the
// real file; zip books extract the entry in place). Library visibility is
// the permission gate, same as ServeFile.
func (mh *MediaHandler) GetAudioTrack(c *echo.Context) error {
mediaUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
trackIndex, err := strconv.Atoi(c.Param("trackIndex"))
if err != nil || trackIndex < 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid track index"})
}
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
}
if !item.LibraryID.Valid {
return c.JSON(http.StatusNotFound, map[string]string{"error": "book has no library"})
}
// Library visibility gate (family-lens rule): a hidden library means
// its audiobooks' bytes are not reachable either.
visibleSet, _, err := mh.visibleLibrarySet(c)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
}
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
}
var payload services.AudioMetadataPayload
if len(item.AudioMetadata) > 0 {
if err := json.Unmarshal(item.AudioMetadata, &payload); err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to parse audio metadata"})
}
}
if trackIndex >= len(payload.Tracks) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "track not found"})
}
track := payload.Tracks[trackIndex]
switch payload.Layout {
case "folder":
fullPath, err := mh.getFullFilePath(c.Request().Context(), item.LibraryID, track.Path)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "track file not found"})
}
f, err := os.Open(fullPath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "track file not found"})
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil || info.IsDir() {
return c.JSON(http.StatusNotFound, map[string]string{"error": "track file not found"})
}
c.Response().Header().Set("Content-Type", audioTrackContentType(track.Path))
c.Response().Header().Set("Cache-Control", "private, max-age=86400")
// http.ServeContent wants a ReadSeeker for Range support; the os
// file provides both.
http.ServeContent(c.Response(), c.Request(), filepath.Base(fullPath), info.ModTime(), f)
return nil
case "zip":
zipPath, err := mh.getFullFilePath(c.Request().Context(), item.LibraryID, item.FilePath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "archive not found"})
}
seeker, err := newZipEntrySeeker(zipPath, track.Path)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "track not found in archive"})
}
defer func() { _ = seeker.Close() }()
c.Response().Header().Set("Content-Type", audioTrackContentType(track.Path))
c.Response().Header().Set("Cache-Control", "private, max-age=86400")
// Zip entries re-open+skip for backward seeks (deflate has no
// random access), which audio bitrates make cheap enough.
http.ServeContent(c.Response(), c.Request(), filepath.Base(track.Path), time.Time{}, seeker)
return nil
default:
// Single-file books stream through the /uploads path; the track
// endpoint has nothing to slice.
return c.JSON(http.StatusNotFound, map[string]string{"error": "book has no track manifest"})
}
}
// audioTrackContentType maps a track path/entry name to its mime type.
func audioTrackContentType(name string) string {
if m := services.MimeTypes[strings.ToLower(filepath.Ext(name))]; m != "" {
return m
}
return "audio/mpeg"
}
// zipEntrySeeker is an io.ReadSeeker over one zip entry. Forward seeks
// discard bytes in the open stream; backward seeks re-open the entry and
// skip — the only option over a deflate stream, and cheap at audio
// bitrates. Owns the archive reader for the life of the request.
type zipEntrySeeker struct {
zrc *zip.ReadCloser
rc io.ReadCloser
pos int64
size int64
entryFn func() (io.ReadCloser, error)
}
func newZipEntrySeeker(zipPath, entryName string) (*zipEntrySeeker, error) {
zrc, err := zip.OpenReader(zipPath)
if err != nil {
return nil, err
}
var entry *zip.File
for _, f := range zrc.File {
if filepath.ToSlash(f.Name) == filepath.ToSlash(entryName) {
entry = f
break
}
}
if entry == nil {
_ = zrc.Close()
return nil, fmt.Errorf("entry %q not found", entryName)
}
open := func() (io.ReadCloser, error) { return entry.Open() }
rc, err := open()
if err != nil {
_ = zrc.Close()
return nil, err
}
return &zipEntrySeeker{
zrc: zrc,
rc: rc,
size: int64(entry.UncompressedSize64),
entryFn: open,
}, nil
}
func (z *zipEntrySeeker) Read(p []byte) (int, error) {
n, err := z.rc.Read(p)
z.pos += int64(n)
return n, err
}
func (z *zipEntrySeeker) Seek(offset int64, whence int) (int64, error) {
var target int64
switch whence {
case io.SeekStart:
target = offset
case io.SeekCurrent:
target = z.pos + offset
case io.SeekEnd:
target = z.size + offset
default:
return z.pos, fmt.Errorf("invalid whence %d", whence)
}
if target < 0 {
return z.pos, fmt.Errorf("negative seek target %d", target)
}
if target < z.pos {
// Deflate streams can't rewind: restart the entry and skip.
if err := z.rc.Close(); err != nil {
return z.pos, err
}
rc, err := z.entryFn()
if err != nil {
return z.pos, err
}
z.rc = rc
z.pos = 0
}
if remaining := target - z.pos; remaining > 0 {
if _, err := io.CopyN(io.Discard, z.rc, remaining); err != nil {
return z.pos, err
}
z.pos = target
}
return z.pos, nil
}
func (z *zipEntrySeeker) Close() error {
_ = z.rc.Close()
return z.zrc.Close()
}
+9 -6
View File
@@ -916,9 +916,16 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
}
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
fileInfo, err := os.Stat(filePath)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
}
// Folder-of-tracks audiobooks point at a book root DIRECTORY: there
// is no single file to stream for OPDS clients (a directory fd reads
// EISDIR). Zip books are real files and keep working.
if fileInfo.IsDir() {
return c.JSON(http.StatusNotFound, map[string]string{"error": "multi-file books have no single download"})
}
// Open file
file, err := os.Open(filePath)
@@ -927,11 +934,7 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
}
defer file.Close()
// Get file info
fileInfo, err := file.Stat()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get file info"})
}
// Get file info (fileInfo already statted above)
// Set headers
filename := filepath.Base(filePath)
+4 -3
View File
@@ -18,10 +18,11 @@ func registerMediaRoutes(cfg *Config) {
// Book download endpoint - same ServeFile flow as /uploads/library-:id/*
// (JWT + library-visibility gated), addressed by media item ID.
protected.GET("/media-items/:id/download", cfg.MediaHandler.ServeFile)
// The format-abstraction chapter list (audio_metadata). Streaming
// itself rides the existing /uploads Range-serving path with cookie
// auth — no separate endpoint.
// The format-abstraction chapter list (audio_metadata). Single-file
// books stream through the existing /uploads Range-serving path;
// multi-file books additionally expose per-track streaming.
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
// Media rating routes (all authenticated users)
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)
+5
View File
@@ -217,6 +217,11 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
mime.AddExtensionType(".m4a", "audio/mp4")
mime.AddExtensionType(".mp3", "audio/mpeg")
mime.AddExtensionType(".flac", "audio/flac")
mime.AddExtensionType(".opus", "audio/ogg")
mime.AddExtensionType(".ogg", "audio/ogg")
mime.AddExtensionType(".oga", "audio/ogg")
mime.AddExtensionType(".aac", "audio/aac")
mime.AddExtensionType(".wav", "audio/wav")
e := cfg.Echo
// Set up validator
+14 -1
View File
@@ -38,7 +38,11 @@ var AllowedExtensions = map[string][]string{
// All four decided breadth formats registered from day one (the
// DOC/LIT doctrine: extensions are a deliberate list); only m4b
// scans richly in Batch A — the chapter abstraction carries the rest.
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac"},
// .zip joins as the zip-of-audio book container (multi-file ingest);
// comics libraries keep using .cbz so the two never collide. The
// browser-playable lossless/next-gen containers join for Batch B
// metadata parity (.wma deliberately absent: browsers can't play it).
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav"},
}
var MimeTypes = map[string]string{
@@ -74,6 +78,15 @@ var MimeTypes = map[string]string{
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
".flac": "audio/flac",
// Opus is registered as audio/ogg per RFC 7845 (Ogg encapsulation);
// every browser <audio> element accepts it that way.
".opus": "audio/ogg",
".ogg": "audio/ogg",
".oga": "audio/ogg",
".aac": "audio/aac",
".wav": "audio/wav",
// Zip-of-audio book container (audiobooks libraries only).
".zip": "application/zip",
}
// GetLibraryTypes retrieves all available library types
+134 -35
View File
@@ -140,6 +140,12 @@ type MediaScanner struct {
newItems int
errors int
job *Job
// audioBookRoots tracks the folder-book roots (absolute paths)
// already ingested during THIS scan walk, so per-track walk visits
// skip instead of reprocessing. Reset at the top of ScanFolders; the
// archive pass reuses it to keep book roots off the missing list.
audioBookRoots map[string]bool
}
// CalibreOPFMetadata represents intermediate parsed metadata from Calibre metadata.opf files
@@ -434,6 +440,7 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
s.totalFiles = 0
s.newItems = 0
s.errors = 0
s.audioBookRoots = make(map[string]bool)
for _, folder := range s.folders {
if ctx.Err() != nil {
@@ -499,6 +506,19 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
}
}
// Multi-file audiobooks first: zip-of-audio books and
// folder-of-tracks books ingest as whole units, and tracks
// inside an already-ingested root skip entirely. Everything
// else (lone audio files, all non-audio media) falls
// through to the single-file path below.
if handled, err := s.maybeProcessAudioBook(ctx, path); handled {
if err != nil {
fmt.Printf("Error processing audio book %s: %v\n", path, err)
s.errors++
}
return nil
}
wasNew, err := s.processMediaFile(ctx, path)
if err != nil {
fmt.Printf("Error processing media file %s: %v\n", path, err)
@@ -569,6 +589,14 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
continue // Skip to next folder to avoid false archivals
}
// Folder-book roots count as present: the item's file_path is the
// DIRECTORY, which the file walk above never emits.
for root := range s.audioBookRoots {
if strings.HasPrefix(root, folder) {
scannedPaths[s.getRelativePath(root)] = true
}
}
for _, item := range dbItems {
if item.FilePath == "" || scannedPaths[item.FilePath] {
continue // File present; unarchive is handled in processMediaFile
@@ -827,16 +855,6 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
metadata = &MediaMetadata{}
}
// Audiobooks: sidecar narrators/chapters + ffprobe facts into the
// audio_metadata JSONB (sidecar-first, ffprobe-fills; the payload's
// shape is the chapter abstraction every format writes).
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
}
// Extract hash information during metadata extraction
hashInfo, formatInfo, err := s.extractHashInfo(path)
if err != nil {
@@ -953,6 +971,21 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
}
}
// Audiobooks: sidecar narrators/chapters + ffprobe facts (incl. the
// full tag fallbacks) into the audio_metadata JSONB. Runs AFTER the
// folder-structure pass so the precedence chain holds: sidecars →
// folder structure → tags → filename. The payload's shape is the
// chapter abstraction every format writes.
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
if metadata.CoverPath == "" {
s.applyEmbeddedCoverFallback(path, metadata)
}
}
// Final fallback if still missing essential metadata
if metadata.Title == "" {
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
@@ -988,6 +1021,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
Contributors: metadata.Contributors,
ContributorsSearch: contributorsSearch,
Narrators: nilToEmpty(metadata.Narrators),
@@ -1118,6 +1152,16 @@ func (s *MediaScanner) applyEmbeddedCoverFallback(path string, metadata *MediaMe
if coverPath, err := s.extractEPUBCover(path); err == nil && coverPath != "" {
metadata.CoverPath = s.getRelativePath(coverPath)
}
default:
// Audio containers (mp3 APIC, m4b/flac embedded picture): one
// ffmpeg call, written as a "<file>.cover.jpg" sibling — the
// same convention the EPUB path uses. Purely optional: absent
// ffmpeg or no embedded art simply leaves the cover empty.
if isAudioFile(path) && !strings.EqualFold(filepath.Ext(path), ".zip") {
if cover := extractEmbeddedAudioCover(path, path); cover != "" {
metadata.CoverPath = s.getRelativePath(cover)
}
}
}
}
@@ -1712,10 +1756,10 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
}
return metadata, nil
default:
// For other formats, return basic metadata
return &MediaMetadata{
Title: strings.TrimSuffix(filepath.Base(path), ext),
}, nil
// Audio containers and other unknown types: NO filename title
// here — processMediaFile's final fallback applies the filename
// LAST, after the audio tag-fallback chain has had its turn.
return &MediaMetadata{}, nil
}
}
@@ -3193,20 +3237,29 @@ func countArchiveImages(filePath string) (int, error) {
// from the existing row so rescans never clobber user customizations; only
// reset-to-scanned-defaults (RescanMediaItem with reset) clears them.
func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.MediaItems, path string) error {
// Re-extract metadata for the update
metadata, err := s.extractMetadata(path)
if err != nil {
fmt.Printf("Warning: failed to extract metadata for force rescan %s: %v\n", path, err)
metadata = &MediaMetadata{}
}
return s.updateMediaItemWithMetadata(ctx, existing, path, nil)
}
// Rescans must RE-DERIVE audio facts, never preserve stale ones
// (gotcha 39's principle applied to audio_metadata).
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
// updateMediaItemWithMetadata is updateMediaItem with an optional
// pre-extracted metadata set. Multi-file audiobooks arrive through
// RescanMediaItem with book-level metadata (sidecars, folder structure,
// first-track tags) because the generic per-file extraction would read
// the book ROOT/CONTAINER as if it were the book file.
func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing database.MediaItems, path string, preExtracted *MediaMetadata) error {
var metadata *MediaMetadata
if preExtracted != nil {
metadata = preExtracted
} else {
// Re-extract metadata for the update
extracted, err := s.extractMetadata(path)
if err != nil {
fmt.Printf("Warning: failed to extract metadata for force rescan %s: %v\n", path, err)
metadata = &MediaMetadata{}
} else if extracted == nil {
metadata = &MediaMetadata{}
} else {
metadata = extracted
}
s.enrichAudioMetadata(path, metadata)
}
// Folder-structure fallback, mirroring the CREATE path
@@ -3233,6 +3286,35 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
}
}
// Audiobooks (single-file): re-derive the audio facts + tag fallbacks
// AFTER the folder-structure pass — the precedence chain (sidecars →
// folder structure → tags → filename) must hold on rescans too.
// Multi-file books arrive with preExtracted metadata already carrying
// their enriched payload.
// Rescans must RE-DERIVE audio facts, never preserve stale ones
// (gotcha 39's principle applied to audio_metadata).
if preExtracted == nil && isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
if metadata.CoverPath == "" {
s.applyEmbeddedCoverFallback(path, metadata)
}
}
// Final filename fallback, mirroring processMediaFile's create path:
// extractMetadata's default branch no longer pre-fills the filename
// as title (embedded tags must outrank it), so the update path needs
// the same last rung — a sidecar-less, tag-less book at a library
// root (no folder structure to match) keeps its filename-derived
// title instead of being blanked. Guarded to single-file updates:
// multi-file representatives are sentinels/containers whose names
// must never leak as titles.
if preExtracted == nil && metadata.Title == "" {
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
// Normalize metadata fields
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
metadata.Tags = utils.NormalizeTags(metadata.Tags)
@@ -3289,21 +3371,26 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
utils.ApplyMetadataOverrides(&params, existing)
params.MetadataOverrides = utils.MergeOverrides(existing.MetadataOverrides)
if _, err = s.db.UpdateMediaItem(ctx, params); err != nil {
if _, err := s.db.UpdateMediaItem(ctx, params); err != nil {
return err
}
// Format flags are file-derived facts, not user metadata: refresh them on
// every update so a force rescan (the admin's backfill tool) can
// reclassify rows created before a classifyFormatGroup arm existed.
// Mirrors the creation path above.
ext := strings.ToLower(filepath.Ext(path))
var epubIsFixedLayout bool
if ext == ".epub" {
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
epubIsFixedLayout = fixedErr == nil && isFixed
// Mirrors the creation path above. Multi-file audiobooks keep the
// "audiobook" group: their representative path is a directory or a zip
// container, which no classify arm could name.
formatGroup, isReflowable, hasFixedLayout := "audiobook", false, false
if preExtracted == nil || preExtracted.AudioMeta == nil || preExtracted.AudioMeta.Layout == "" {
ext := strings.ToLower(filepath.Ext(path))
var epubIsFixedLayout bool
if ext == ".epub" {
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
epubIsFixedLayout = fixedErr == nil && isFixed
}
formatGroup, isReflowable, hasFixedLayout = classifyFormatGroup(ext, epubIsFixedLayout)
}
formatGroup, isReflowable, hasFixedLayout := classifyFormatGroup(ext, epubIsFixedLayout)
mimeType := s.getMimeType(path)
if err := s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
ID: existing.ID,
@@ -3367,6 +3454,16 @@ func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.U
return fmt.Errorf("failed to stat media file: %w", err)
}
// Multi-file audiobooks (folder/zip layout) must NOT go through the
// generic per-file updater: it would read the book ROOT directory (or
// the zip container) as if it were the book file — deriving a filename
// title and classifying format_group "unknown", which drops the item
// out of the audio reader. The book-level path rebuilds the track
// manifest and re-derives metadata from sidecars/tags instead.
if item.FormatGroup == "audiobook" && audioLayoutOf(item.AudioMetadata) != "" {
return s.rescanMultiFileAudioBook(ctx, item, fullPath)
}
if err := s.updateMediaItem(ctx, item, fullPath); err != nil {
return fmt.Errorf("failed to update media item: %w", err)
}
@@ -4012,6 +4109,8 @@ func (s *MediaScanner) detectFormatType(filePath string) string {
return "pdf"
case ".cbz", ".cbr", ".cb7", ".cbt":
return "comic_archive"
case ".m4b", ".m4a", ".mp3", ".flac", ".opus", ".ogg", ".oga", ".aac", ".wav":
return "audiobook"
case ".mobi":
return "mobi"
case ".azw", ".azw3":
@@ -4042,7 +4141,7 @@ func classifyFormatGroup(ext string, epubIsFixedLayout bool) (formatGroup string
return "fixed_layout", false, true
case ".cbz", ".cbr", ".cb7", ".cbt":
return "comic_archive", false, true
case ".m4b", ".m4a", ".mp3", ".flac":
case ".m4b", ".m4a", ".mp3", ".flac", ".opus", ".ogg", ".oga", ".aac", ".wav":
// Audio is neither reflowable text nor fixed-layout pages; the
// "audiobook" group routes readers to the audio player instead
// of any document engine.
+222 -5
View File
@@ -9,10 +9,17 @@ package services
// until a rescan runs with ffprobe present).
//
// The chapters array written into media_items.audio_metadata IS the
// format abstraction: embedded m4b chapters and (later) folder-as-book
// files both produce {index, title, start, end} entries, so the locator
// {chapter, offset_seconds} and the /chapters endpoint never learn which
// file model produced them.
// format abstraction: embedded m4b chapters, folder-as-book files and
// zip-of-audio books all produce {index, title, start, end} entries, so
// the locator {chapter, offset_seconds} and the /chapters endpoint never
// learn which file model produced them.
//
// Multi-file books (layout "folder" | "zip", see
// media_scanner_audio_multifile.go) additionally carry a tracks array:
// one entry per playable file, with its global-seconds start/end and the
// path the /audio/:trackIndex streaming endpoint resolves. Single-file
// books leave layout and tracks empty — the <audio> element streams the
// whole file exactly as before.
import (
"encoding/json"
@@ -33,6 +40,21 @@ type AudioChapter struct {
End float64 `json:"end"`
}
// AudioTrack is one playable file of a multi-file audiobook. Path is the
// library-relative file path (layout "folder") or the in-zip entry name
// (layout "zip"); the /audio/:trackIndex endpoint resolves it to bytes.
// Start/End are cumulative global seconds across the book — the same
// coordinate system chapters, bookmarks and audio_offset_seconds use.
type AudioTrack struct {
Index int `json:"index"`
Path string `json:"path"`
Title string `json:"title,omitempty"`
Start float64 `json:"start"`
End float64 `json:"end"`
DurationSeconds float64 `json:"duration_seconds,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"`
}
// AudioMetadataPayload is the JSONB stored in media_items.audio_metadata.
// Only the audio paths read it — ebook paths never do (the kobo_metadata
// precedent).
@@ -44,16 +66,33 @@ type AudioMetadataPayload struct {
ASIN string `json:"asin,omitempty"`
Abridged *bool `json:"abridged,omitempty"`
Chapters []AudioChapter `json:"chapters,omitempty"`
// Layout: "" = single file (the Batch A model), "folder" = a
// directory of track files, "zip" = a zip archive of tracks.
Layout string `json:"layout,omitempty"`
// Tracks: one entry per playable file for multi-file layouts. Empty
// for single-file books (the whole file IS the track).
Tracks []AudioTrack `json:"tracks,omitempty"`
}
func isAudioFile(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".m4b", ".m4a", ".mp3", ".flac":
case ".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav":
return true
}
return false
}
// isAudioTrackFile reports whether a path is a PLAYABLE track of a
// multi-file book. Zips are book CONTAINERS (layout "zip"), never tracks
// of a folder book — a nested zip inside a book root must not join the
// track list, and pre-grouping one-track strays were never zips.
func isAudioTrackFile(path string) bool {
if strings.EqualFold(filepath.Ext(path), ".zip") {
return false
}
return isAudioFile(path)
}
// ffprobeAvailable is resolved once per scan process; a missing binary is
// a degradation, not an error.
var ffprobeAvailable = func() bool {
@@ -61,10 +100,55 @@ var ffprobeAvailable = func() bool {
return err == nil
}()
// ffmpegAvailable mirrors ffprobeAvailable for the embedded-cover
// extractor (ffprobe cannot write image streams; ffmpeg can).
var ffmpegAvailable = func() bool {
_, err := exec.LookPath("ffmpeg")
return err == nil
}()
// extractEmbeddedAudioCover pulls the embedded cover art (mp3 APIC /
// m4b covr / flac picture) out of an audio file with one ffmpeg call —
// the attached picture presents itself as the file's only video stream.
// Writes outputBase+".cover.jpg" following the EPUB sidecar convention
// ("<book>.cover.jpg"). Returns "" when ffmpeg is absent, the file
// carries no picture, or anything fails — purely optional enrichment.
func extractEmbeddedAudioCover(inputPath, outputBase string) string {
if !ffmpegAvailable {
return ""
}
outPath := outputBase + ".cover.jpg"
cmd := exec.Command("ffmpeg",
"-y", "-v", "error",
"-i", inputPath,
"-map", "0:v:0",
"-frames:v", "1",
outPath,
)
if out, err := cmd.CombinedOutput(); err != nil {
// "matches no streams" is the no-embedded-art case; stay quiet
// about it, log anything unexpected.
if len(out) > 0 && !strings.Contains(string(out), "matches no streams") {
fmt.Printf("ffmpeg cover extraction note for %s: %s\n", inputPath, strings.TrimSpace(string(out)))
}
return ""
}
if info, err := os.Stat(outPath); err != nil || info.Size() == 0 {
_ = os.Remove(outPath)
return ""
}
return outPath
}
type ffprobeReport struct {
Format struct {
Duration flexFloat `json:"duration"`
BitRate flexFloat `json:"bit_rate"`
// Tags: ffprobe normalizes well-known atoms to lowercase keys but
// passes freeform keys (Audible's `----:com.apple.iTunes:*`)
// through verbatim — a map with case-insensitive lookup is the
// only honest shape. See tag().
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
CodecName string `json:"codec_name"`
@@ -134,6 +218,24 @@ func runFFProbe(path string) *ffprobeReport {
return &report
}
// tag returns the first non-empty ffprobe tag among the candidate keys,
// matched case-insensitively and trimmed. Empty string when none match.
func (r *ffprobeReport) tag(keys ...string) string {
if r == nil {
return ""
}
for _, key := range keys {
for k, v := range r.Format.Tags {
if strings.EqualFold(k, key) {
if trimmed := strings.TrimSpace(v); trimmed != "" {
return trimmed
}
}
}
}
return ""
}
// enrichAudioMetadata merges ffprobe facts into the (possibly sidecar-
// seeded) audio payload, creating it when no sidecar existed. Sidecar
// narrators/chapters win (curated); ffprobe fills duration, codec,
@@ -192,6 +294,24 @@ func (s *MediaScanner) enrichAudioMetadata(path string, metadata *MediaMetadata)
if payload.Abridged != nil {
metadata.Abridged = *payload.Abridged
}
// Tag fallbacks run LAST in the single-file chain (sidecars → folder
// structure handled by the caller → tags → filename): they only fill
// fields the earlier sources left empty.
applyAudioTagFallbacks(metadata, report)
// CUE sheets are the last chapter source: sidecar chapters and
// embedded container chapters (m4b) outrank them.
if len(payload.Chapters) == 0 {
if cueChapters := readCueChapters(path); len(cueChapters) > 0 {
payload.Chapters = cueChapters
if payload.DurationSeconds == 0 {
if last := cueChapters[len(cueChapters)-1]; last.End > 0 {
payload.DurationSeconds = last.End
}
}
}
}
}
// parseSidecarAudioFields extracts the audio-only fields from a
@@ -261,6 +381,103 @@ func marshalAudioMetadata(payload *AudioMetadataPayload) []byte {
return out
}
// applyAudioTagFallbacks fills EMPTY media metadata from the file's own
// tags — the last rung of the ingestion precedence chain (curated
// sidecars → folder structure → embedded tags → filename). Field map:
//
// album / album_artist / artist → title / author
// composer → narrators (Audible's own
// convention: the narrator lives in the composer tag)
// grouping → series
// ----:com.apple.iTunes:SERIES[-PART] → series + index (Libation
// preserves these Audible freeform atoms)
// ----:com.apple.iTunes:SUBTITLE → joined into the title
// ("Title: Subtitle"), mirroring the ABS sidecar path
// ----:com.apple.iTunes:ASIN / ABRIDGED → asin / abridged flag
// date → publish date; genre → genre
//
// Abridged is the one field where an explicit sidecar false must not be
// overridden by a tag — the payload pointer marks sidecar presence.
func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
if metadata == nil || report == nil {
return
}
if metadata.Title == "" {
if album := report.tag("album"); album != "" {
metadata.Title = album
}
}
// Subtitle joins the title Calibre-style, mirroring the ABS sidecar
// path — there is no separate subtitle column. Skip when the title
// already contains it (tools sometimes pre-join).
if subtitle := report.tag("----:com.apple.itunes:subtitle", "subtitle"); subtitle != "" && metadata.Title != "" &&
!strings.Contains(strings.ToLower(metadata.Title), strings.ToLower(subtitle)) {
metadata.Title = metadata.Title + ": " + subtitle
}
if metadata.Author == "" {
if artist := report.tag("album_artist"); artist != "" {
metadata.Author = artist
} else if artist := report.tag("artist"); artist != "" {
metadata.Author = artist
}
}
if len(metadata.Narrators) == 0 {
// Kept as ONE narrator string: composer values have no reliable
// multi-narrator delimiter, and splitting "Vance, Simon" would
// manufacture two fake people. Admins can edit the column.
if composer := report.tag("composer"); composer != "" {
metadata.Narrators = []string{composer}
if metadata.AudioMeta != nil && len(metadata.AudioMeta.Narrators) == 0 {
metadata.AudioMeta.Narrators = []string{composer}
}
}
}
if metadata.Series == "" {
if series := report.tag("grouping", "----:com.apple.itunes:series"); series != "" {
metadata.Series = series
if part := report.tag("----:com.apple.itunes:series-part"); part != "" {
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 32); err == nil {
metadata.SeriesNumber = int32(idx)
}
}
}
}
if metadata.ASIN == "" {
if asin := report.tag("----:com.apple.itunes:asin", "asin"); asin != "" {
metadata.ASIN = asin
if metadata.AudioMeta != nil && metadata.AudioMeta.ASIN == "" {
metadata.AudioMeta.ASIN = asin
}
}
}
if metadata.AudioMeta == nil || metadata.AudioMeta.Abridged == nil {
if !metadata.Abridged {
switch strings.ToLower(report.tag("----:com.apple.itunes:abridged")) {
case "1", "true", "yes":
metadata.Abridged = true
if metadata.AudioMeta != nil {
abridged := true
metadata.AudioMeta.Abridged = &abridged
}
}
}
}
if metadata.PublishDate.IsZero() {
if date := report.tag("date"); date != "" {
for _, layout := range []string{"2006-01-02", "2006-01", "2006"} {
if t, err := time.Parse(layout, strings.TrimSpace(date)); err == nil {
metadata.PublishDate = t
break
}
}
}
}
if metadata.Genre == "" {
metadata.Genre = report.tag("genre")
}
}
// audioDurationLabel renders the duration for logs/detail surfaces.
func audioDurationLabel(seconds float64) string {
if seconds <= 0 {
@@ -0,0 +1,173 @@
package services
// CUE sheet support: a `.cue` sidecar describing the chapter marks of a
// single big audio file (the classic CD-rip shape: one flac/mp3 + one
// cue). CUE is the LOWEST-priority chapter source — curated sidecar
// chapters and embedded m4b chapters outrank it — and a purely optional
// one: no cue, no chapters, no error.
import (
"bufio"
"os"
"path/filepath"
"strconv"
"strings"
)
// cueFramesPerSecond is the CD framing CUE INDEX timestamps use
// (MM:SS:FF with 75 frames per second).
const cueFramesPerSecond = 75.0
// cueTimeToSeconds converts a CUE INDEX timestamp ("03:25:12") to
// seconds. Minutes may exceed 59 and hours are folded into the minutes
// field per the spec (MM:SS:FF, MM unbounded).
func cueTimeToSeconds(v string) float64 {
parts := strings.Split(strings.TrimSpace(v), ":")
if len(parts) != 3 {
return 0
}
minutes, err1 := strconv.ParseFloat(strings.TrimSpace(parts[0]), 64)
seconds, err2 := strconv.ParseFloat(strings.TrimSpace(parts[1]), 64)
frames, err3 := strconv.ParseFloat(strings.TrimSpace(parts[2]), 64)
if err1 != nil || err2 != nil || err3 != nil {
return 0
}
return minutes*60 + seconds + frames/cueFramesPerSecond
}
// readCueChapters finds a CUE sheet describing the given audio file and
// returns its tracks as chapters (global seconds, ends derived from the
// next start; the last end stays 0 = unknown). A cue matches when its
// FILE directive names the audio file (basename, case-insensitive), or
// — for single-cue directories — when the directory holds exactly one.
func readCueChapters(path string) []AudioChapter {
dir := filepath.Dir(path)
base := strings.ToLower(filepath.Base(path))
cuePaths, err := filepath.Glob(filepath.Join(dir, "*.cue"))
if err != nil || len(cuePaths) == 0 {
return nil
}
var matched string
for _, cuePath := range cuePaths {
if referencedByCue(cuePath, base) {
matched = cuePath
break
}
}
if matched == "" && len(cuePaths) == 1 {
// Single-cue directory: the cue almost certainly describes the
// only audio file next to it.
matched = cuePaths[0]
}
if matched == "" {
return nil
}
return parseCueSheet(matched, base)
}
// referencedByCue reports whether the cue's FILE directive names the
// given audio basename.
func referencedByCue(cuePath, audioBase string) bool {
f, err := os.Open(cuePath)
if err != nil {
return false
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if len(line) >= 4 && strings.EqualFold(line[:4], "FILE") {
rest := strings.TrimSpace(line[4:])
rest = strings.Trim(rest, "\"")
// FILE "name" WAVE / FILE name MP3 — drop the type word.
if idx := strings.LastIndexAny(rest, " \t"); idx != -1 {
rest = strings.TrimSpace(rest[:idx])
}
rest = strings.Trim(rest, "\"")
return strings.EqualFold(filepath.Base(rest), audioBase)
}
}
return false
}
// parseCueSheet extracts TRACK/TITLE/INDEX 01 entries as chapters.
// INDEX timestamps are absolute from the file start per the spec; the
// pregap (INDEX 00) is ignored. Multi-file cues contribute only the
// TRACK blocks under the FILE directive naming our audio file (or all
// blocks when the cue declares no FILE at all).
func parseCueSheet(cuePath, audioBase string) []AudioChapter {
f, err := os.Open(cuePath)
if err != nil {
return nil
}
defer func() { _ = f.Close() }()
var chapters []AudioChapter
var current *AudioChapter
title := ""
inOurFile := true
flush := func() {
if current != nil && current.Start >= 0 {
current.Title = strings.TrimSpace(title)
if current.Title == "" {
current.Title = "Chapter " + strconv.Itoa(current.Index+1)
}
if n := len(chapters); n > 0 && current.Start >= chapters[n-1].Start {
chapters[n-1].End = current.Start
}
chapters = append(chapters, *current)
}
current = nil
title = ""
}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
upper := strings.ToUpper(line)
switch {
case strings.HasPrefix(upper, "FILE"):
flush()
rest := strings.Trim(strings.TrimSpace(line[4:]), "\"")
if idx := strings.LastIndexAny(rest, " \t"); idx != -1 {
rest = strings.TrimSpace(rest[:idx])
}
rest = strings.Trim(rest, "\"")
inOurFile = strings.EqualFold(filepath.Base(rest), audioBase)
case strings.HasPrefix(upper, "TRACK"):
flush()
if !inOurFile {
continue
}
current = &AudioChapter{Index: len(chapters)}
if fields := strings.Fields(line); len(fields) >= 2 {
if n, err := strconv.Atoi(fields[1]); err == nil && n > 0 {
current.Index = n - 1
}
}
case strings.HasPrefix(upper, "TITLE") && current != nil:
if q1 := strings.Index(line, "\""); q1 != -1 {
if q2 := strings.LastIndex(line, "\""); q2 > q1 {
title = line[q1+1 : q2]
}
}
case strings.HasPrefix(upper, "INDEX"):
if current == nil || !inOurFile {
continue
}
if fields := strings.Fields(line); len(fields) >= 3 && fields[1] == "01" {
current.Start = cueTimeToSeconds(fields[2])
}
}
}
flush()
if len(chapters) == 0 {
return nil
}
return chapters
}
@@ -0,0 +1,277 @@
package services
// Unit tests for the exhaustive audiobook metadata ingestion: CUE
// sheets, m3u playlists, tag fallbacks (incl. Audible freeform atoms)
// and tag-based track ordering.
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestCueTimeToSeconds(t *testing.T) {
cases := map[string]float64{
"00:00:00": 0,
"01:00:00": 60,
"03:25:12": 3*60 + 25 + 12.0/75.0,
"75:00:37": 75*60 + 37.0/75.0, // minutes exceed 59 per spec
"not-a-time": 0,
"1:2": 0,
}
for in, want := range cases {
if got := cueTimeToSeconds(in); (got-want) > 0.001 || (want-got) > 0.001 {
t.Errorf("cueTimeToSeconds(%q) = %v, want %v", in, got, want)
}
}
}
func TestParseCueSheet(t *testing.T) {
dir := t.TempDir()
cuePath := filepath.Join(dir, "book.cue")
cue := `TITLE "Cue Book"
FILE "book.flac" WAVE
TRACK 01 AUDIO
TITLE "Opening"
INDEX 01 00:00:00
TRACK 02 AUDIO
TITLE "The Middle Bit"
INDEX 01 00:01:30
TRACK 03 AUDIO
TITLE "Closing"
INDEX 01 00:03:00
FILE "other.flac" WAVE
TRACK 01 AUDIO
TITLE "Foreign Track"
INDEX 01 00:00:00
`
if err := os.WriteFile(cuePath, []byte(cue), 0o644); err != nil {
t.Fatal(err)
}
chapters := parseCueSheet(cuePath, "book.flac")
if len(chapters) != 3 {
t.Fatalf("chapters = %d (%+v), want 3 (foreign FILE block excluded)", len(chapters), chapters)
}
if chapters[0].Title != "Opening" || chapters[0].Start != 0 {
t.Fatalf("first = %+v", chapters[0])
}
// MM:SS:FF: "00:01:30" = 1 second + 30/75 frames = 1.4s
if chapters[1].Start != 1.4 || chapters[1].End != 3.0 {
t.Fatalf("second = %+v", chapters[1])
}
if chapters[2].End != 0 {
t.Fatalf("last end must stay 0 (unknown): %+v", chapters[2])
}
}
func TestReadCueChaptersMatched(t *testing.T) {
dir := t.TempDir()
cue := "FILE \"audio.mp3\" WAVE\n TRACK 01 AUDIO\n TITLE \"One\"\n INDEX 01 00:00:00\n TRACK 02 AUDIO\n TITLE \"Two\"\n INDEX 01 00:00:10\n"
if err := os.WriteFile(filepath.Join(dir, "audio.cue"), []byte(cue), 0o644); err != nil {
t.Fatal(err)
}
chapters := readCueChapters(filepath.Join(dir, "audio.mp3"))
if len(chapters) != 2 {
t.Fatalf("chapters = %d", len(chapters))
}
// No cue at all → nil, never an error.
if chapters := readCueChapters(filepath.Join(dir, "other.mp3")); chapters != nil {
t.Fatalf("expected nil for missing cue, got %+v", chapters)
}
}
func TestFindAndApplyPlaylistHint(t *testing.T) {
dir := t.TempDir()
playlist := "#EXTM3U\n#EXTINF:120,Chapter Zero\n02_Second.mp3\n#EXTINF:60,Chapter One\n./01_First.mp3\nmissing.mp3\n"
if err := os.WriteFile(filepath.Join(dir, "book.m3u"), []byte(playlist), 0o644); err != nil {
t.Fatal(err)
}
// Exactly one playlist → hint; two → none.
hint := findPlaylistHint(dir)
if hint == nil {
t.Fatal("expected hint")
}
if err := os.WriteFile(filepath.Join(dir, "other.m3u8"), []byte("#EXTM3U\n"), 0o644); err != nil {
t.Fatal(err)
}
if findPlaylistHint(dir) != nil {
t.Fatal("multiple playlists must produce no hint")
}
abs := []string{
filepath.Join(dir, "01_First.mp3"),
filepath.Join(dir, "02_Second.mp3"),
filepath.Join(dir, "10_Later.mp3"), // not in playlist → appended last
}
ordered := hint.reorder(abs)
if len(ordered) != 3 || ordered[0] != abs[1] || ordered[1] != abs[0] || ordered[2] != abs[2] {
t.Fatalf("playlist order not applied: %v", ordered)
}
if got := hint.titleFor(abs[0]); got != "Chapter One" {
t.Fatalf("titleFor(./01_First.mp3) = %q", got)
}
if got := hint.titleFor(abs[1]); got != "Chapter Zero" {
t.Fatalf("titleFor(02_Second.mp3) = %q", got)
}
if got := hint.titleFor(abs[2]); got != "" {
t.Fatalf("unlisted track must have no title, got %q", got)
}
if h := (*playlistHint)(nil); h.titleFor(abs[0]) != "" {
t.Fatal("nil hint must be safe")
}
}
func syntheticReport(tags map[string]string) *ffprobeReport {
r := &ffprobeReport{}
r.Format.Tags = tags
return r
}
func TestApplyAudioTagFallbacks(t *testing.T) {
t.Run("full fill", func(t *testing.T) {
meta := &MediaMetadata{}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
"album": "The Tagged Album",
"album_artist": "Tag Author",
"artist": "Wrong Author",
"composer": "Tag Narrator",
"grouping": "Tag Series",
"genre": "Audiobook",
"date": "2020",
"----:com.apple.iTunes:SUBTITLE": "Or, A Life",
"----:com.apple.iTunes:SERIES": "Freeform Series",
"----:com.apple.iTunes:SERIES-PART": "4",
"----:com.apple.iTunes:ASIN": "B0ASIN1234",
"----:com.apple.iTunes:ABRIDGED": "1",
}))
if meta.Title != "The Tagged Album: Or, A Life" {
t.Fatalf("title = %q", meta.Title)
}
if meta.Author != "Tag Author" {
t.Fatalf("author = %q", meta.Author)
}
if len(meta.Narrators) != 1 || meta.Narrators[0] != "Tag Narrator" {
t.Fatalf("narrators = %v", meta.Narrators)
}
// grouping outranks freeform SERIES for the series name only
// because it is checked first; both are tag-level fallbacks.
if meta.Series == "" {
t.Fatal("series not filled from tags")
}
if meta.SeriesNumber != 4 {
t.Fatalf("series number = %d", meta.SeriesNumber)
}
if meta.ASIN != "B0ASIN1234" {
t.Fatalf("asin = %q", meta.ASIN)
}
if !meta.Abridged {
t.Fatal("abridged not filled")
}
if meta.PublishDate.Year() != 2020 {
t.Fatalf("publish date = %v", meta.PublishDate)
}
if meta.Genre != "Audiobook" {
t.Fatalf("genre = %q", meta.Genre)
}
})
t.Run("fills only empty fields", func(t *testing.T) {
meta := &MediaMetadata{Title: "Sidecar Title", Author: "Sidecar Author", Narrators: []string{"Sidecar Narrator"}}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
"album": "Tag Album", "artist": "Tag Artist", "composer": "Tag Narrator",
}))
if meta.Title != "Sidecar Title" || meta.Author != "Sidecar Author" {
t.Fatalf("tags must not override earlier sources: %+v", meta)
}
if len(meta.Narrators) != 1 || meta.Narrators[0] != "Sidecar Narrator" {
t.Fatalf("narrators = %v", meta.Narrators)
}
})
t.Run("explicit sidecar false survives abridged tag", func(t *testing.T) {
abridgedFalse := false
meta := &MediaMetadata{AudioMeta: &AudioMetadataPayload{Abridged: &abridgedFalse}}
applyAudioTagFallbacks(meta, syntheticReport(map[string]string{
"----:com.apple.iTunes:ABRIDGED": "1",
}))
if meta.Abridged {
t.Fatal("explicit sidecar false must not be overridden")
}
})
t.Run("nil report is a no-op", func(t *testing.T) {
meta := &MediaMetadata{}
applyAudioTagFallbacks(meta, nil)
if meta.Title != "" {
t.Fatal("nil report must change nothing")
}
})
}
func TestParseTagOrdinal(t *testing.T) {
cases := map[string]int{
"2": 2,
"3/9": 3,
" 12 ": 12,
"": 0,
"x": 0,
"0": 0,
"2/": 2,
"-1": 0,
}
for in, want := range cases {
if got := parseTagOrdinal(in); got != want {
t.Errorf("parseTagOrdinal(%q) = %d, want %d", in, got, want)
}
}
}
func TestOrderTracksByTags(t *testing.T) {
mk := func(disc, track int, rel string) audioTrackInfo {
return audioTrackInfo{disc: disc, trackNum: track, relPath: rel}
}
t.Run("tag order wins when complete", func(t *testing.T) {
probed := []audioTrackInfo{
mk(2, 1, "b/01.mp3"), mk(1, 2, "a/02.mp3"), mk(1, 10, "a/10.mp3"),
}
orderTracksByTags(probed)
want := []string{"a/02.mp3", "a/10.mp3", "b/01.mp3"}
for i, tr := range probed {
if tr.relPath != want[i] {
t.Fatalf("order = %v", probed)
}
}
})
t.Run("incomplete coverage keeps natural order", func(t *testing.T) {
probed := []audioTrackInfo{mk(1, 1, "a/01.mp3"), mk(0, 0, "a/02.mp3")}
orderTracksByTags(probed)
if probed[0].relPath != "a/01.mp3" || probed[1].relPath != "a/02.mp3" {
t.Fatalf("order = %v", probed)
}
})
}
func TestTagLookupCaseInsensitive(t *testing.T) {
r := syntheticReport(map[string]string{
"ALBUM": "Cased Album",
"----:com.apple.iTunes:ASIN": "B0CASE",
})
if got := r.tag("album"); got != "Cased Album" {
t.Fatalf("tag(album) = %q", got)
}
if got := r.tag("album", "title"); got != "Cased Album" {
t.Fatalf("multi-key lookup = %q", got)
}
if got := r.tag("----:COM.APPLE.ITUNES:asin"); !strings.EqualFold(got, "B0CASE") {
t.Fatalf("freeform case-insensitive lookup = %q", got)
}
if got := r.tag("missing"); got != "" {
t.Fatalf("missing key = %q", got)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,203 @@
package services
// Unit tests for the multi-file audiobook grouping/manifest helpers
// (media_scanner_audio_multifile.go). Pure filesystem logic — no DB.
import (
"archive/zip"
"os"
"path/filepath"
"testing"
)
func TestNaturalLess(t *testing.T) {
cases := []struct {
a, b string
less bool
}{
{"01 Track.mp3", "02 Track.mp3", true},
{"02 Track.mp3", "10 Track.mp3", true}, // numeric, not lexicographic
{"10 Track.mp3", "9 Track.mp3", false},
{"Track 2", "Track 10", true},
{"abc", "abd", true},
{"01", "1", false}, // leading zeros compare equal → strict-less false
{"a1b", "a1c", true},
{"Disc 1", "Disc 2", true},
}
for _, tc := range cases {
if got := naturalLess(tc.a, tc.b); got != tc.less {
t.Errorf("naturalLess(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.less)
}
}
}
func TestSortTracksNatural(t *testing.T) {
in := []string{"/x/10.mp3", "/x/2.mp3", "/x/1.mp3"}
sortTracksNatural(in)
want := []string{"/x/1.mp3", "/x/2.mp3", "/x/10.mp3"}
for i := range want {
if in[i] != want[i] {
t.Fatalf("sorted = %v, want %v", in, want)
}
}
}
// writeDummyTrack creates a file with plausible audio bytes (never probed
// in these tests — only structure matters).
func writeDummyTrack(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("ID3 fake audio bytes"), 0o644); err != nil {
t.Fatal(err)
}
}
func TestBookRootForTrackFile(t *testing.T) {
root := t.TempDir()
// Layout:
// root/Author/Title/01.mp3, 02.mp3 → one book at Title
// root/Author/Parts/CD1/01.mp3, CD2/01.mp3 → one book at Parts
// root/Author/Lone/book.mp3 → no folder book
writeDummyTrack(t, filepath.Join(root, "Author", "Title", "01.mp3"))
writeDummyTrack(t, filepath.Join(root, "Author", "Title", "02.mp3"))
writeDummyTrack(t, filepath.Join(root, "Author", "Parts", "CD1", "01.mp3"))
writeDummyTrack(t, filepath.Join(root, "Author", "Parts", "CD2", "01.mp3"))
writeDummyTrack(t, filepath.Join(root, "Author", "Lone", "book.mp3"))
t.Run("simple track folder", func(t *testing.T) {
got, tracks, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Title", "02.mp3"), root)
if !ok {
t.Fatalf("expected folder book, got none")
}
if got != filepath.Join(root, "Author", "Title") {
t.Fatalf("root = %q", got)
}
if len(tracks) != 2 {
t.Fatalf("tracks = %v", tracks)
}
})
t.Run("disc folders merge into parent", func(t *testing.T) {
got, tracks, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Parts", "CD1", "01.mp3"), root)
if !ok {
t.Fatalf("expected folder book")
}
if got != filepath.Join(root, "Author", "Parts") {
t.Fatalf("root = %q (disc folders must merge up)", got)
}
if len(tracks) != 2 {
t.Fatalf("tracks = %v", tracks)
}
})
t.Run("lone file is not a folder book", func(t *testing.T) {
_, _, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Lone", "book.mp3"), root)
if ok {
t.Fatalf("single track must not form a folder book")
}
})
t.Run("sibling books never merge at author level", func(t *testing.T) {
writeDummyTrack(t, filepath.Join(root, "Author", "BookB", "01.mp3"))
writeDummyTrack(t, filepath.Join(root, "Author", "BookB", "02.mp3"))
got, _, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Title", "01.mp3"), root)
if !ok || got != filepath.Join(root, "Author", "Title") {
t.Fatalf("root = %q, ok = %v; Title/BookB must stay separate", got, ok)
}
})
}
func TestBuildMultiFileAudioPayload(t *testing.T) {
tracks := []audioTrackInfo{
{relPath: "Author/Title/01.mp3", title: "Chapter One", duration: 100, size: 10},
{relPath: "Author/Title/02.mp3", title: "Chapter Two", duration: 50, size: 5},
}
payload := buildMultiFileAudioPayload("folder", nil, tracks)
if payload.Layout != "folder" {
t.Fatalf("layout = %q", payload.Layout)
}
if payload.DurationSeconds != 150 {
t.Fatalf("duration = %v", payload.DurationSeconds)
}
if len(payload.Tracks) != 2 {
t.Fatalf("tracks = %d", len(payload.Tracks))
}
if payload.Tracks[1].Start != 100 || payload.Tracks[1].End != 150 {
t.Fatalf("cumulative bounds wrong: %+v", payload.Tracks[1])
}
// No sidecar chapters: tracks ARE the chapters.
if len(payload.Chapters) != 2 || payload.Chapters[0].Start != 0 || payload.Chapters[1].Start != 100 {
t.Fatalf("chapters = %+v", payload.Chapters)
}
// Curated sidecar chapters keep priority; tracks still carry bounds.
sidecar := &AudioMetadataPayload{
Chapters: []AudioChapter{{Index: 0, Title: "Part I", Start: 0, End: 150}},
}
payload2 := buildMultiFileAudioPayload("zip", sidecar, tracks)
if len(payload2.Chapters) != 1 || payload2.Chapters[0].Title != "Part I" {
t.Fatalf("sidecar chapters must win: %+v", payload2.Chapters)
}
if len(payload2.Tracks) != 2 {
t.Fatalf("tracks lost: %+v", payload2.Tracks)
}
}
func TestZipAudioEntries(t *testing.T) {
zipPath := filepath.Join(t.TempDir(), "book.zip")
f, err := os.Create(zipPath)
if err != nil {
t.Fatal(err)
}
w := zip.NewWriter(f)
for _, name := range []string{
"__MACOSX/._junk.mp3",
".hidden.mp3",
"02 second.mp3",
"10 tenth.mp3",
"01 first.mp3",
"cover.jpg",
"notes.txt",
} {
if _, err := w.Create(name); err != nil {
t.Fatal(err)
}
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
_ = f.Close()
entries, err := zipAudioEntries(zipPath)
if err != nil {
t.Fatal(err)
}
want := []string{"01 first.mp3", "02 second.mp3", "10 tenth.mp3"}
if len(entries) != len(want) {
t.Fatalf("entries = %v", entries)
}
for i, e := range entries {
if e.name != want[i] {
t.Fatalf("entry[%d] = %q, want %q", i, e.name, want[i])
}
}
}
func TestCleanTrackTitle(t *testing.T) {
cases := map[string]string{
"/x/01_Genesis001.mp3": "Genesis001",
"/x/01 - Track.mp3": "Track",
"/x/12_.Intro.mp3": "Intro",
"/x/Chapter 3.mp3": "Chapter 3",
"/x/JustTitle.m4b": "JustTitle",
"/x/01.mp3": "01", // number-only name: number stays the title
}
for in, want := range cases {
if got := cleanTrackTitle(in); got != want {
t.Errorf("cleanTrackTitle(%q) = %q, want %q", in, got, want)
}
}
}
+1
View File
@@ -12,6 +12,7 @@ const (
FormatGroupReflowable FormatGroup = "reflowable"
FormatGroupFixedLayout FormatGroup = "fixed_layout"
FormatGroupComicArchive FormatGroup = "comic_archive"
FormatGroupAudiobook FormatGroup = "audiobook"
FormatGroupUnknown FormatGroup = "unknown"
)
+10 -1
View File
@@ -87,6 +87,15 @@ func ConvertProgress(sourceFormat, targetFormat FormatGroup, sourceData map[stri
result["panel"] = panel
}
case FormatGroupAudiobook:
// Audio progress is percentage + global seconds; there is no page
// grid to re-derive, so the percentage carries across as-is and
// the second-based locator follows when present.
result["percentage"] = percentage
if offset, ok := sourceData["audio_offset_seconds"].(int); ok {
result["audio_offset_seconds"] = offset
}
default:
return nil, fmt.Errorf("unsupported target format: %s", targetFormat)
}
@@ -108,7 +117,7 @@ func extractPercentage(sourceFormat FormatGroup, sourceData map[string]interface
}
}
case FormatGroupFixedLayout, FormatGroupComicArchive:
case FormatGroupFixedLayout, FormatGroupComicArchive, FormatGroupAudiobook:
if page, ok := sourceData["page"].(int); ok {
if total, ok := sourceData["total_pages"].(int); ok && total > 0 {
return float64(page) / float64(total)