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
+4
-1
@@ -59,7 +59,10 @@ RUN cp -r /go/pkg/mod /app/go-module-cache
|
|||||||
# This stage is ONLY used for running tests, never deployed to production
|
# This stage is ONLY used for running tests, never deployed to production
|
||||||
FROM golang:1.26-alpine AS test-runner
|
FROM golang:1.26-alpine AS test-runner
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates curl poppler-utils
|
# ffmpeg/ffprobe match the runtime stage: the audio ingestion (tag
|
||||||
|
# fallbacks, embedded covers, cue probing) must be exercised in tests
|
||||||
|
# exactly as it runs in production.
|
||||||
|
RUN apk --no-cache add ca-certificates curl poppler-utils ffmpeg
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
info:
|
||||||
|
name: Get Audio Chapters
|
||||||
|
type: http
|
||||||
|
seq: 30
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: "{{base_url}}/api/media-items/{{media_item_id}}/chapters"
|
||||||
|
headers:
|
||||||
|
- name: ""
|
||||||
|
value: application/json
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Get Audio Chapters
|
||||||
|
|
||||||
|
The format-abstraction chapter list for audiobooks
|
||||||
|
(media_items.audio_metadata). Single-file books return `layout: ""` and
|
||||||
|
an empty `tracks` array; folder-of-tracks books return `layout:
|
||||||
|
"folder"` and zip books `layout: "zip"`, each with one `tracks` entry
|
||||||
|
per playable file (global-seconds start/end plus the per-track `url`).
|
||||||
|
|
||||||
|
**Method:** GET
|
||||||
|
|
||||||
|
**Endpoint:** /api/media-items/{id}/chapters
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
info:
|
||||||
|
name: Get Audio Track
|
||||||
|
type: http
|
||||||
|
seq: 31
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: GET
|
||||||
|
url: "{{base_url}}/api/media-items/{{media_item_id}}/audio/0"
|
||||||
|
headers:
|
||||||
|
- name: Range
|
||||||
|
value: bytes=0-1023
|
||||||
|
auth: inherit
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Get Audio Track
|
||||||
|
|
||||||
|
Streams one track of a multi-file audiobook (folder or zip layout).
|
||||||
|
Range requests are honored: folder tracks are served from the real
|
||||||
|
file; zip tracks are extracted in place (backward seeks re-open the
|
||||||
|
entry). Requires the item's library to be visible to the requester
|
||||||
|
(403 otherwise). Single-file books have no track manifest — 404.
|
||||||
|
|
||||||
|
**Method:** GET
|
||||||
|
|
||||||
|
**Endpoint:** /api/media-items/{id}/audio/{trackIndex}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -244,8 +244,12 @@ CREATE TABLE IF NOT EXISTS media_items (
|
|||||||
-- { "narrators": [], "duration_seconds": N, "codec_profile": "LC",
|
-- { "narrators": [], "duration_seconds": N, "codec_profile": "LC",
|
||||||
-- "bit_rate": N, "asin": "...", "chapters": [
|
-- "bit_rate": N, "asin": "...", "chapters": [
|
||||||
-- { "index": 0, "title": "...", "start": 0.0, "end": 168.0 } ] }
|
-- { "index": 0, "title": "...", "start": 0.0, "end": 168.0 } ] }
|
||||||
-- The chapters array IS the format abstraction: embedded m4b chapters
|
-- The chapters array IS the format abstraction: embedded m4b chapters,
|
||||||
-- and (later) folder-as-book files both write this exact shape.
|
-- folder-of-tracks books and zip-of-audio books all write this exact
|
||||||
|
-- shape. Multi-file books add additive keys: "layout"
|
||||||
|
-- ("folder" | "zip"; absent = single file) and "tracks" (one entry
|
||||||
|
-- per playable file with global-seconds start/end plus the path the
|
||||||
|
-- /audio/:trackIndex endpoint resolves).
|
||||||
audio_metadata JSONB
|
audio_metadata JSONB
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -102,3 +102,20 @@ cover_image_path, has_conflict, format_group, badge`.
|
|||||||
origin — its rows are unchanged. Request-parse widening documented per
|
origin — its rows are unchanged. Request-parse widening documented per
|
||||||
rule 2; response shapes untouched; locked by
|
rule 2; response shapes untouched; locked by
|
||||||
TestOriginWriterAttribution.
|
TestOriginWriterAttribution.
|
||||||
|
|
||||||
|
## Changelog entry — multi-file audiobooks (2026-10-18)
|
||||||
|
|
||||||
|
- `GET /api/media-items/:id/chapters` gained ADDITIVE response keys
|
||||||
|
`layout` ("" for single-file books, `"folder"` or `"zip"` for
|
||||||
|
multi-file books) and `tracks[]` (`index, url, title, path, start,
|
||||||
|
end, duration_seconds, size_bytes`; empty for single-file books).
|
||||||
|
Existing keys (`media_item_id, chapters, duration_seconds,
|
||||||
|
codec_profile`) unchanged — no pin existed on this endpoint, so no
|
||||||
|
pin widened; recorded here per rule 2's spirit.
|
||||||
|
- New sibling endpoint `GET /api/media-items/:id/audio/:trackIndex`
|
||||||
|
streams one track of a multi-file book (Range-capable). New surface,
|
||||||
|
not a modification; gated by library visibility like `ServeFile`
|
||||||
|
(403 on hidden libraries).
|
||||||
|
- `media_items.audio_metadata` payload gained additive JSON keys
|
||||||
|
`layout` and `tracks` (see schema.sql comment). Consumers parse
|
||||||
|
additively; stored single-file payloads are unchanged.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Get Audio Chapters
|
||||||
|
|
||||||
|
The format-abstraction chapter list for audiobooks, sourced from
|
||||||
|
`media_items.audio_metadata`. Embedded m4b chapters, folder-of-tracks
|
||||||
|
books and zip-of-audio books all write the same chapter shape; the
|
||||||
|
locator `{chapter, audio_offset_seconds}` and this endpoint never learn
|
||||||
|
which file model produced them.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/media-items/{id}/chapters`
|
||||||
|
**Auth**: Required (JWT)
|
||||||
|
|
||||||
|
## Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"media_item_id": "…",
|
||||||
|
"chapters": [
|
||||||
|
{ "index": 0, "title": "Chapter One", "start": 0.0, "end": 168.0 }
|
||||||
|
],
|
||||||
|
"duration_seconds": 36000.0,
|
||||||
|
"codec_profile": "mp3",
|
||||||
|
"layout": "folder",
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"url": "/api/media-items/{id}/audio/0",
|
||||||
|
"title": "01 First",
|
||||||
|
"path": "Author/Title/01 First.mp3",
|
||||||
|
"start": 0.0,
|
||||||
|
"end": 168.0,
|
||||||
|
"duration_seconds": 168.0,
|
||||||
|
"size_bytes": 2700000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `layout` is `""` for single-file books (the Batch A model — the whole
|
||||||
|
file streams through the `/uploads` path), `"folder"` for a directory
|
||||||
|
of track files and `"zip"` for a zip-of-audio book.
|
||||||
|
- `tracks` is empty for single-file books and carries one entry per
|
||||||
|
playable file otherwise. `start`/`end` are cumulative GLOBAL seconds —
|
||||||
|
the same coordinate system chapters, bookmarks and
|
||||||
|
`audio_offset_seconds` use.
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | ------------------------------- |
|
||||||
|
| 400 | Invalid media item id |
|
||||||
|
| 401 | Missing/invalid token |
|
||||||
|
| 404 | Book not found |
|
||||||
|
| 500 | audio_metadata failed to parse |
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Get Audio Track
|
||||||
|
|
||||||
|
Streams one playable track of a multi-file audiobook (folder-of-tracks
|
||||||
|
or zip-of-audio book). Track indexes and bounds come from the track
|
||||||
|
manifest on [Get Audio Chapters](get_audio_chapters.md).
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/media-items/{id}/audio/{trackIndex}`
|
||||||
|
**Auth**: Required (JWT — Bearer header or session cookie; the web
|
||||||
|
player's `<audio>` element authenticates by cookie)
|
||||||
|
|
||||||
|
## Serving
|
||||||
|
|
||||||
|
- **Folder books**: the track is served straight from its real file
|
||||||
|
(`http.ServeContent`) — full `Range` support.
|
||||||
|
- **Zip books**: the entry is extracted in place. Forward seeks discard
|
||||||
|
bytes in the open stream; backward seeks re-open the entry and skip
|
||||||
|
(deflate has no random access — cheap at audio bitrates). `Range`
|
||||||
|
requests are honored.
|
||||||
|
|
||||||
|
Single-file books have no track manifest — they stream through the
|
||||||
|
`/uploads` path exactly as before and this endpoint answers 404.
|
||||||
|
|
||||||
|
## Library visibility
|
||||||
|
|
||||||
|
The item's library must be visible to the requester; a hidden library
|
||||||
|
answers **403** on every track (same gate as the download endpoint).
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | -------------------------------------------- |
|
||||||
|
| 400 | Invalid media item id or track index |
|
||||||
|
| 401 | Missing/invalid token |
|
||||||
|
| 403 | Library not visible to the requester |
|
||||||
|
| 404 | Book/track not found, or single-file book |
|
||||||
|
| 500 | audio_metadata failed to parse |
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Range: bytes=0-1023" \
|
||||||
|
"http://localhost:8765/api/media-items/550e8400-.../audio/0"
|
||||||
|
```
|
||||||
@@ -90,8 +90,11 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
### Absent data
|
### Absent data
|
||||||
|
|
||||||
`Audio Tracks` (Audiobookshelf's per-file count) is deliberately absent:
|
`Audio Tracks` (Audiobookshelf's per-file count) is deliberately absent:
|
||||||
Bookhoard audiobooks are one file per book until multi-file support
|
multi-file audiobooks (folder-of-tracks / zip books) now ingest as ONE
|
||||||
lands, so a file count would just equal `items`. See HANDOFF item 9.
|
item whose track list lives in `audio_metadata.tracks`, so a file count
|
||||||
|
would still not equal a per-book "tracks" metric without a new query.
|
||||||
|
Add it by aggregating `jsonb_array_length(audio_metadata->'tracks')` if
|
||||||
|
the panel is ever wanted.
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Audiobook metadata: sources & precedence
|
||||||
|
|
||||||
|
Every audiobook ingestion path (single-file, folder-of-tracks, zip-of-
|
||||||
|
audio) fills the same fields from the same ranked sources. Each source
|
||||||
|
**only fills fields the earlier ones left empty** — curated data always
|
||||||
|
outranks guesses — and every source is optional: a book still ingests
|
||||||
|
(title from the filename) when all of them are absent. Admin edits made
|
||||||
|
after ingest are protected by the metadata-override mechanism on
|
||||||
|
rescans, regardless of source quality.
|
||||||
|
|
||||||
|
## Book-level metadata (title, author, narrators, series, …)
|
||||||
|
|
||||||
|
| Rank | Source | Feeds |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | **Sidecars** — `metadata.opf` (Calibre), `metadata.json` (Audiobookshelf/Libation, array-or-string tolerant) | title (+subtitle join), author, series + index, narrators, abridged, ASIN, tags/genres, dates, publisher, description |
|
||||||
|
| 2 | **Folder structure** — `Author/Title`, `Author/Series, Book #N - Title`, `Author/Series/Title` | title, author, series + index |
|
||||||
|
| 3 | **Embedded tags** (via ffprobe) | `album` → title; `album_artist` → author (`artist` fallback); `composer` → **narrators** (Audible's own convention); `grouping` → series; `date` → publish date; `genre` → genre; Audible freeform atoms `----:com.apple.iTunes:SERIES`/`:SERIES-PART`/`:SUBTITLE`/`:ASIN`/`:ABRIDGED` |
|
||||||
|
| 4 | **Filename/folder name** | title last of all (created only — never lets a rescan blank a title) |
|
||||||
|
|
||||||
|
Abridged is the one guarded field: an explicit sidecar `false` wins even
|
||||||
|
when a tag says `true` (the sidecar's presence is tracked, not just its
|
||||||
|
value). Narrators from the composer tag stay ONE string — composer
|
||||||
|
values have no reliable multi-narrator delimiter, and splitting
|
||||||
|
"Vance, Simon" would manufacture fake people.
|
||||||
|
|
||||||
|
## Chapters (the `{index, title, start, end}` array)
|
||||||
|
|
||||||
|
| Rank | Source |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Sidecar `metadata.json` `chapters[]` (curated, global seconds) |
|
||||||
|
| 2 | Embedded m4b chapter atoms (`chpl` / QuickTime text track, read by ffprobe) |
|
||||||
|
| 3 | **CUE sheet** — `.cue` next to the audio file (FILE-directive matched, `INDEX 01 MM:SS:FF` at 75 fps); the classic one-big-flac + cue rip shape |
|
||||||
|
| 4 | Track boundaries — for folder/zip books, each file is a chapter |
|
||||||
|
|
||||||
|
## Track ordering (multi-file books)
|
||||||
|
|
||||||
|
| Rank | Source |
|
||||||
|
|---|---|
|
||||||
|
| 1 | **Playlist** — exactly one `.m3u`/`.m3u8` among the book root's direct children; its entry order wins and `#EXTINF` names the tracks |
|
||||||
|
| 2 | **Tags** — `(disc, track)` sort when *every* track carries a track number (fixes flat `01.mp3` dumps from multi-disc rips) |
|
||||||
|
| 3 | **Natural sort** of relative paths (`02` before `10`), recursive — the default |
|
||||||
|
|
||||||
|
Track display titles, highest first: `movement_name` → `title` tag →
|
||||||
|
playlist `#EXTINF` → cleaned filename (leading ordinals/separators
|
||||||
|
stripped).
|
||||||
|
|
||||||
|
## Book grouping (which files form one book)
|
||||||
|
|
||||||
|
- The book root is the nearest ancestor holding audio files DIRECTLY,
|
||||||
|
merged upward through disc-part directories (`CD1`, `Disc 2`, `Part 3`,
|
||||||
|
`05`) when the parent has no audio of its own.
|
||||||
|
- Sibling directories are NEVER merged (two books under one author stay
|
||||||
|
two books); a root with a single track falls back to the single-file
|
||||||
|
model.
|
||||||
|
- A lone `.zip` of audio files is its own book (layout "zip"); a folder
|
||||||
|
of tracks is one book with `file_path` = the directory (layout
|
||||||
|
"folder"); a single audio file is the Batch A model (no layout key).
|
||||||
|
- Rescans take a fast path (track count + total size + known durations
|
||||||
|
unchanged → no re-probe) and absorb pre-grouping one-track strays.
|
||||||
|
|
||||||
|
## Covers
|
||||||
|
|
||||||
|
| Rank | Source |
|
||||||
|
|---|---|
|
||||||
|
| 1 | Sidecar `cover.jpg` / `folder.jpg` / … next to the book |
|
||||||
|
| 2 | **Embedded art** (`APIC` mp3, `covr` m4b, flac picture) — one optional ffmpeg call, written as a `<book>.cover.jpg` sibling following the EPUB convention |
|
||||||
|
|
||||||
|
## Accepted containers (audiobook libraries)
|
||||||
|
|
||||||
|
`.m4b` `.m4a` `.mp3` `.flac` `.zip` (book container) `.opus` `.ogg`
|
||||||
|
`.oga` `.aac` `.wav` — all browser-playable; served with their real
|
||||||
|
media types (`audio/ogg` per RFC 7845 for Opus-in-Ogg, etc.).
|
||||||
|
|
||||||
|
## Documented non-goals
|
||||||
|
|
||||||
|
- **ID3v2 CHAP/CTOC chapter frames** — the written MP3 standard for
|
||||||
|
embedded chapters, near-zero audiobook adoption; would need a
|
||||||
|
hand-rolled ID3v2.4 parser. Revisit only if real files show up.
|
||||||
|
- **NFO sidecars** — no genuine audiobook NFO standard exists (Jellyfin's
|
||||||
|
Bookshelf uses Calibre OPF, which we read).
|
||||||
|
- **ReplayGain / EBU R128 loudness tags** — a playback feature, not
|
||||||
|
ingestion; separate plan if ever wanted.
|
||||||
|
- **`.wma`** — browsers cannot play it; excluded deliberately.
|
||||||
@@ -574,6 +574,11 @@ type Querier interface {
|
|||||||
UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error)
|
UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error)
|
||||||
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
|
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
|
||||||
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, 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)
|
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
||||||
// Update media item format
|
// Update media item format
|
||||||
UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error)
|
UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error)
|
||||||
|
|||||||
@@ -14297,6 +14297,111 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams
|
|||||||
return i, err
|
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
|
const UpdateMediaItemChapterMetadata = `-- name: UpdateMediaItemChapterMetadata :one
|
||||||
UPDATE media_items
|
UPDATE media_items
|
||||||
SET chapter_metadata = $2, updated_at = NOW()
|
SET chapter_metadata = $2, updated_at = NOW()
|
||||||
|
|||||||
@@ -3229,3 +3229,17 @@ WHERE mi.archived_at IS NULL
|
|||||||
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
||||||
ORDER BY mi.file_size DESC, mi.title ASC
|
ORDER BY mi.file_size DESC, mi.title ASC
|
||||||
LIMIT 5;
|
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
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/zip"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
@@ -2550,9 +2551,16 @@ func (mh *MediaHandler) ServeFile(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists
|
// 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"})
|
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))
|
ext := strings.ToLower(filepath.Ext(fullPath))
|
||||||
contentType := services.MimeTypes[ext]
|
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
|
// GetAudioChapters handles GET /api/media-items/:id/chapters — the
|
||||||
// format-abstraction chapter list for audiobooks (media_items
|
// format-abstraction chapter list for audiobooks (media_items
|
||||||
// .audio_metadata). Embedded m4b chapters and folder-as-book files write
|
// .audio_metadata). Embedded m4b chapters, folder-of-tracks books and zip
|
||||||
// the same shape. Empty list when the item isn't audio or hasn't been
|
// books write the same shape. Multi-file books additionally carry
|
||||||
// probed.
|
// `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 {
|
func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
|
||||||
mediaID := c.Param("id")
|
mediaID := c.Param("id")
|
||||||
mediaUUID, err := uuid.Parse(mediaID)
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
@@ -2662,6 +2673,20 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
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
|
var payload services.AudioMetadataPayload
|
||||||
if len(item.AudioMetadata) > 0 {
|
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{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"media_item_id": item.ID,
|
"media_item_id": item.ID,
|
||||||
"chapters": payload.Chapters,
|
"chapters": payload.Chapters,
|
||||||
"duration_seconds": payload.DurationSeconds,
|
"duration_seconds": payload.DurationSeconds,
|
||||||
"codec_profile": payload.CodecProfile,
|
"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()
|
||||||
|
}
|
||||||
@@ -916,9 +916,16 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists
|
// 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"})
|
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
|
// Open file
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
@@ -927,11 +934,7 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
// Get file info
|
// Get file info (fileInfo already statted above)
|
||||||
fileInfo, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to get file info"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set headers
|
// Set headers
|
||||||
filename := filepath.Base(filePath)
|
filename := filepath.Base(filePath)
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ func registerMediaRoutes(cfg *Config) {
|
|||||||
// Book download endpoint - same ServeFile flow as /uploads/library-:id/*
|
// Book download endpoint - same ServeFile flow as /uploads/library-:id/*
|
||||||
// (JWT + library-visibility gated), addressed by media item ID.
|
// (JWT + library-visibility gated), addressed by media item ID.
|
||||||
protected.GET("/media-items/:id/download", cfg.MediaHandler.ServeFile)
|
protected.GET("/media-items/:id/download", cfg.MediaHandler.ServeFile)
|
||||||
// The format-abstraction chapter list (audio_metadata). Streaming
|
// The format-abstraction chapter list (audio_metadata). Single-file
|
||||||
// itself rides the existing /uploads Range-serving path with cookie
|
// books stream through the existing /uploads Range-serving path;
|
||||||
// auth — no separate endpoint.
|
// multi-file books additionally expose per-track streaming.
|
||||||
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
|
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)
|
// Media rating routes (all authenticated users)
|
||||||
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)
|
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)
|
||||||
|
|||||||
@@ -217,6 +217,11 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
mime.AddExtensionType(".m4a", "audio/mp4")
|
mime.AddExtensionType(".m4a", "audio/mp4")
|
||||||
mime.AddExtensionType(".mp3", "audio/mpeg")
|
mime.AddExtensionType(".mp3", "audio/mpeg")
|
||||||
mime.AddExtensionType(".flac", "audio/flac")
|
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
|
e := cfg.Echo
|
||||||
|
|
||||||
// Set up validator
|
// Set up validator
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ var AllowedExtensions = map[string][]string{
|
|||||||
// All four decided breadth formats registered from day one (the
|
// All four decided breadth formats registered from day one (the
|
||||||
// DOC/LIT doctrine: extensions are a deliberate list); only m4b
|
// DOC/LIT doctrine: extensions are a deliberate list); only m4b
|
||||||
// scans richly in Batch A — the chapter abstraction carries the rest.
|
// 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{
|
var MimeTypes = map[string]string{
|
||||||
@@ -74,6 +78,15 @@ var MimeTypes = map[string]string{
|
|||||||
".m4a": "audio/mp4",
|
".m4a": "audio/mp4",
|
||||||
".mp3": "audio/mpeg",
|
".mp3": "audio/mpeg",
|
||||||
".flac": "audio/flac",
|
".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
|
// GetLibraryTypes retrieves all available library types
|
||||||
|
|||||||
@@ -140,6 +140,12 @@ type MediaScanner struct {
|
|||||||
newItems int
|
newItems int
|
||||||
errors int
|
errors int
|
||||||
job *Job
|
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
|
// 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.totalFiles = 0
|
||||||
s.newItems = 0
|
s.newItems = 0
|
||||||
s.errors = 0
|
s.errors = 0
|
||||||
|
s.audioBookRoots = make(map[string]bool)
|
||||||
|
|
||||||
for _, folder := range s.folders {
|
for _, folder := range s.folders {
|
||||||
if ctx.Err() != nil {
|
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)
|
wasNew, err := s.processMediaFile(ctx, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error processing media file %s: %v\n", path, err)
|
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
|
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 {
|
for _, item := range dbItems {
|
||||||
if item.FilePath == "" || scannedPaths[item.FilePath] {
|
if item.FilePath == "" || scannedPaths[item.FilePath] {
|
||||||
continue // File present; unarchive is handled in processMediaFile
|
continue // File present; unarchive is handled in processMediaFile
|
||||||
@@ -827,16 +855,6 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
|||||||
metadata = &MediaMetadata{}
|
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
|
// Extract hash information during metadata extraction
|
||||||
hashInfo, formatInfo, err := s.extractHashInfo(path)
|
hashInfo, formatInfo, err := s.extractHashInfo(path)
|
||||||
if err != nil {
|
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
|
// Final fallback if still missing essential metadata
|
||||||
if metadata.Title == "" {
|
if metadata.Title == "" {
|
||||||
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
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},
|
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||||
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
||||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||||
|
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
||||||
Contributors: metadata.Contributors,
|
Contributors: metadata.Contributors,
|
||||||
ContributorsSearch: contributorsSearch,
|
ContributorsSearch: contributorsSearch,
|
||||||
Narrators: nilToEmpty(metadata.Narrators),
|
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 != "" {
|
if coverPath, err := s.extractEPUBCover(path); err == nil && coverPath != "" {
|
||||||
metadata.CoverPath = s.getRelativePath(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
|
return metadata, nil
|
||||||
default:
|
default:
|
||||||
// For other formats, return basic metadata
|
// Audio containers and other unknown types: NO filename title
|
||||||
return &MediaMetadata{
|
// here — processMediaFile's final fallback applies the filename
|
||||||
Title: strings.TrimSuffix(filepath.Base(path), ext),
|
// LAST, after the audio tag-fallback chain has had its turn.
|
||||||
}, nil
|
return &MediaMetadata{}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3193,20 +3237,29 @@ func countArchiveImages(filePath string) (int, error) {
|
|||||||
// from the existing row so rescans never clobber user customizations; only
|
// from the existing row so rescans never clobber user customizations; only
|
||||||
// reset-to-scanned-defaults (RescanMediaItem with reset) clears them.
|
// reset-to-scanned-defaults (RescanMediaItem with reset) clears them.
|
||||||
func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.MediaItems, path string) error {
|
func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.MediaItems, path string) error {
|
||||||
// Re-extract metadata for the update
|
return s.updateMediaItemWithMetadata(ctx, existing, path, nil)
|
||||||
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{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rescans must RE-DERIVE audio facts, never preserve stale ones
|
// updateMediaItemWithMetadata is updateMediaItem with an optional
|
||||||
// (gotcha 39's principle applied to audio_metadata).
|
// pre-extracted metadata set. Multi-file audiobooks arrive through
|
||||||
if isAudioFile(path) {
|
// RescanMediaItem with book-level metadata (sidecars, folder structure,
|
||||||
if metadata.AudioMeta == nil {
|
// first-track tags) because the generic per-file extraction would read
|
||||||
metadata.AudioMeta = readSidecarAudio(path)
|
// 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
|
// 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
|
// Normalize metadata fields
|
||||||
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
|
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
|
||||||
metadata.Tags = utils.NormalizeTags(metadata.Tags)
|
metadata.Tags = utils.NormalizeTags(metadata.Tags)
|
||||||
@@ -3289,21 +3371,26 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
|
|||||||
utils.ApplyMetadataOverrides(¶ms, existing)
|
utils.ApplyMetadataOverrides(¶ms, existing)
|
||||||
params.MetadataOverrides = utils.MergeOverrides(existing.MetadataOverrides)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format flags are file-derived facts, not user metadata: refresh them on
|
// Format flags are file-derived facts, not user metadata: refresh them on
|
||||||
// every update so a force rescan (the admin's backfill tool) can
|
// every update so a force rescan (the admin's backfill tool) can
|
||||||
// reclassify rows created before a classifyFormatGroup arm existed.
|
// reclassify rows created before a classifyFormatGroup arm existed.
|
||||||
// Mirrors the creation path above.
|
// Mirrors the creation path above. Multi-file audiobooks keep the
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
// "audiobook" group: their representative path is a directory or a zip
|
||||||
var epubIsFixedLayout bool
|
// container, which no classify arm could name.
|
||||||
if ext == ".epub" {
|
formatGroup, isReflowable, hasFixedLayout := "audiobook", false, false
|
||||||
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
|
if preExtracted == nil || preExtracted.AudioMeta == nil || preExtracted.AudioMeta.Layout == "" {
|
||||||
epubIsFixedLayout = fixedErr == nil && isFixed
|
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)
|
mimeType := s.getMimeType(path)
|
||||||
if err := s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
|
if err := s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
|
||||||
ID: existing.ID,
|
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)
|
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 {
|
if err := s.updateMediaItem(ctx, item, fullPath); err != nil {
|
||||||
return fmt.Errorf("failed to update media item: %w", err)
|
return fmt.Errorf("failed to update media item: %w", err)
|
||||||
}
|
}
|
||||||
@@ -4012,6 +4109,8 @@ func (s *MediaScanner) detectFormatType(filePath string) string {
|
|||||||
return "pdf"
|
return "pdf"
|
||||||
case ".cbz", ".cbr", ".cb7", ".cbt":
|
case ".cbz", ".cbr", ".cb7", ".cbt":
|
||||||
return "comic_archive"
|
return "comic_archive"
|
||||||
|
case ".m4b", ".m4a", ".mp3", ".flac", ".opus", ".ogg", ".oga", ".aac", ".wav":
|
||||||
|
return "audiobook"
|
||||||
case ".mobi":
|
case ".mobi":
|
||||||
return "mobi"
|
return "mobi"
|
||||||
case ".azw", ".azw3":
|
case ".azw", ".azw3":
|
||||||
@@ -4042,7 +4141,7 @@ func classifyFormatGroup(ext string, epubIsFixedLayout bool) (formatGroup string
|
|||||||
return "fixed_layout", false, true
|
return "fixed_layout", false, true
|
||||||
case ".cbz", ".cbr", ".cb7", ".cbt":
|
case ".cbz", ".cbr", ".cb7", ".cbt":
|
||||||
return "comic_archive", false, true
|
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
|
// Audio is neither reflowable text nor fixed-layout pages; the
|
||||||
// "audiobook" group routes readers to the audio player instead
|
// "audiobook" group routes readers to the audio player instead
|
||||||
// of any document engine.
|
// of any document engine.
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ package services
|
|||||||
// until a rescan runs with ffprobe present).
|
// until a rescan runs with ffprobe present).
|
||||||
//
|
//
|
||||||
// The chapters array written into media_items.audio_metadata IS the
|
// The chapters array written into media_items.audio_metadata IS the
|
||||||
// format abstraction: embedded m4b chapters and (later) folder-as-book
|
// format abstraction: embedded m4b chapters, folder-as-book files and
|
||||||
// files both produce {index, title, start, end} entries, so the locator
|
// zip-of-audio books all produce {index, title, start, end} entries, so
|
||||||
// {chapter, offset_seconds} and the /chapters endpoint never learn which
|
// the locator {chapter, offset_seconds} and the /chapters endpoint never
|
||||||
// file model produced them.
|
// 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 (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -33,6 +40,21 @@ type AudioChapter struct {
|
|||||||
End float64 `json:"end"`
|
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.
|
// AudioMetadataPayload is the JSONB stored in media_items.audio_metadata.
|
||||||
// Only the audio paths read it — ebook paths never do (the kobo_metadata
|
// Only the audio paths read it — ebook paths never do (the kobo_metadata
|
||||||
// precedent).
|
// precedent).
|
||||||
@@ -44,16 +66,33 @@ type AudioMetadataPayload struct {
|
|||||||
ASIN string `json:"asin,omitempty"`
|
ASIN string `json:"asin,omitempty"`
|
||||||
Abridged *bool `json:"abridged,omitempty"`
|
Abridged *bool `json:"abridged,omitempty"`
|
||||||
Chapters []AudioChapter `json:"chapters,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 {
|
func isAudioFile(path string) bool {
|
||||||
switch strings.ToLower(filepath.Ext(path)) {
|
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 true
|
||||||
}
|
}
|
||||||
return false
|
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
|
// ffprobeAvailable is resolved once per scan process; a missing binary is
|
||||||
// a degradation, not an error.
|
// a degradation, not an error.
|
||||||
var ffprobeAvailable = func() bool {
|
var ffprobeAvailable = func() bool {
|
||||||
@@ -61,10 +100,55 @@ var ffprobeAvailable = func() bool {
|
|||||||
return err == nil
|
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 {
|
type ffprobeReport struct {
|
||||||
Format struct {
|
Format struct {
|
||||||
Duration flexFloat `json:"duration"`
|
Duration flexFloat `json:"duration"`
|
||||||
BitRate flexFloat `json:"bit_rate"`
|
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"`
|
} `json:"format"`
|
||||||
Streams []struct {
|
Streams []struct {
|
||||||
CodecName string `json:"codec_name"`
|
CodecName string `json:"codec_name"`
|
||||||
@@ -134,6 +218,24 @@ func runFFProbe(path string) *ffprobeReport {
|
|||||||
return &report
|
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-
|
// enrichAudioMetadata merges ffprobe facts into the (possibly sidecar-
|
||||||
// seeded) audio payload, creating it when no sidecar existed. Sidecar
|
// seeded) audio payload, creating it when no sidecar existed. Sidecar
|
||||||
// narrators/chapters win (curated); ffprobe fills duration, codec,
|
// narrators/chapters win (curated); ffprobe fills duration, codec,
|
||||||
@@ -192,6 +294,24 @@ func (s *MediaScanner) enrichAudioMetadata(path string, metadata *MediaMetadata)
|
|||||||
if payload.Abridged != nil {
|
if payload.Abridged != nil {
|
||||||
metadata.Abridged = *payload.Abridged
|
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
|
// parseSidecarAudioFields extracts the audio-only fields from a
|
||||||
@@ -261,6 +381,103 @@ func marshalAudioMetadata(payload *AudioMetadataPayload) []byte {
|
|||||||
return out
|
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.
|
// audioDurationLabel renders the duration for logs/detail surfaces.
|
||||||
func audioDurationLabel(seconds float64) string {
|
func audioDurationLabel(seconds float64) string {
|
||||||
if seconds <= 0 {
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ const (
|
|||||||
FormatGroupReflowable FormatGroup = "reflowable"
|
FormatGroupReflowable FormatGroup = "reflowable"
|
||||||
FormatGroupFixedLayout FormatGroup = "fixed_layout"
|
FormatGroupFixedLayout FormatGroup = "fixed_layout"
|
||||||
FormatGroupComicArchive FormatGroup = "comic_archive"
|
FormatGroupComicArchive FormatGroup = "comic_archive"
|
||||||
|
FormatGroupAudiobook FormatGroup = "audiobook"
|
||||||
FormatGroupUnknown FormatGroup = "unknown"
|
FormatGroupUnknown FormatGroup = "unknown"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ func ConvertProgress(sourceFormat, targetFormat FormatGroup, sourceData map[stri
|
|||||||
result["panel"] = panel
|
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:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported target format: %s", targetFormat)
|
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 page, ok := sourceData["page"].(int); ok {
|
||||||
if total, ok := sourceData["total_pages"].(int); ok && total > 0 {
|
if total, ok := sourceData["total_pages"].(int); ok && total > 0 {
|
||||||
return float64(page) / float64(total)
|
return float64(page) / float64(total)
|
||||||
|
|||||||
Reference in new issue
Block a user