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.
1168 lines
40 KiB
Go
1168 lines
40 KiB
Go
package services
|
|
|
|
// Multi-file audiobook ingestion: folder-of-tracks books and zip-of-audio
|
|
// books. Both produce the SAME payload shape single-file books write into
|
|
// media_items.audio_metadata — the chapters array stays the format
|
|
// abstraction — plus a tracks array naming one playable file per track
|
|
// (see media_scanner_audio.go). The /audio/:trackIndex endpoint resolves
|
|
// those paths to bytes; the web player maps global seconds ⇄ track/local
|
|
// offset, so progress/bookmarks keep their existing coordinates.
|
|
//
|
|
// Grouping rule (folder books): the book root is the nearest ancestor of
|
|
// the track file that holds audio files DIRECTLY, merged upward through
|
|
// disc/part-style directories ("CD1", "Disc 2", "Part 3", "05") whose
|
|
// parent has no audio of its own. So
|
|
//
|
|
// Author/Title/01.mp3, 02.mp3 → book at Author/Title
|
|
// Author/Title/CD1/*.mp3, CD2/*.mp3 → one book at Author/Title
|
|
// Author/BookA/*.mp3, Author/BookB/*.mp3 → two books (siblings never merge)
|
|
//
|
|
// A root holding fewer than two tracks falls back to the single-file path.
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/utils"
|
|
)
|
|
|
|
// discPartPattern matches directory names that group discs/parts of one
|
|
// book rather than naming a standalone book: "CD1", "disc 2", "Part3",
|
|
// "pt. 1", "Vol 2", or a bare two-digit ordering number ("01").
|
|
var discPartPattern = regexp.MustCompile(`(?i)^(disc|cd|part|pt|vol|volume)\s*\.?\s*\d{0,3}$|^\d{1,2}$`)
|
|
|
|
// naturalLess orders strings with embedded numbers numerically, so
|
|
// "02 Track" sorts before "10 Track". Digit runs compare by value; other
|
|
// bytes compare bytewise.
|
|
func naturalLess(a, b string) bool {
|
|
i, j := 0, 0
|
|
for i < len(a) && j < len(b) {
|
|
ca, cb := a[i], b[j]
|
|
da, db := ca >= '0' && ca <= '9', cb >= '0' && cb <= '9'
|
|
switch {
|
|
case da && db:
|
|
si, sj := i, j
|
|
for i < len(a) && a[i] >= '0' && a[i] <= '9' {
|
|
i++
|
|
}
|
|
for j < len(b) && b[j] >= '0' && b[j] <= '9' {
|
|
j++
|
|
}
|
|
na, nb := strings.TrimLeft(a[si:i], "0"), strings.TrimLeft(b[sj:j], "0")
|
|
if len(na) != len(nb) {
|
|
return len(na) < len(nb)
|
|
}
|
|
if c := strings.Compare(na, nb); c != 0 {
|
|
return c < 0
|
|
}
|
|
default:
|
|
if ca != cb {
|
|
return ca < cb
|
|
}
|
|
i++
|
|
j++
|
|
}
|
|
}
|
|
return len(a)-i < len(b)-j
|
|
}
|
|
|
|
// sortTracksNatural orders full paths by natural comparison.
|
|
func sortTracksNatural(paths []string) {
|
|
sort.Slice(paths, func(x, y int) bool { return naturalLess(paths[x], paths[y]) })
|
|
}
|
|
|
|
// subtreeAudioTracks collects playable audio tracks under dir
|
|
// recursively, natural-sorted by path. Zips don't count (containers,
|
|
// never tracks — see isAudioTrackFile).
|
|
func subtreeAudioTracks(dir string) []string {
|
|
var tracks []string
|
|
_ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return nil // unreadable subtree: skip silently, best effort
|
|
}
|
|
if isAudioTrackFile(path) {
|
|
tracks = append(tracks, path)
|
|
}
|
|
return nil
|
|
})
|
|
sortTracksNatural(tracks)
|
|
return tracks
|
|
}
|
|
|
|
// directAudioCount counts playable audio files directly inside dir (not
|
|
// recursive).
|
|
func directAudioCount(dir string) int {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
n := 0
|
|
for _, e := range entries {
|
|
if !e.IsDir() && isAudioTrackFile(e.Name()) {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// bookRootDirForTrackFile resolves the folder-book root DIRECTORY for an
|
|
// audio file without walking its subtree (cheap enough to call for every
|
|
// track visit). ok is false when no ancestor holds audio directly.
|
|
func bookRootDirForTrackFile(path, libraryRoot string) (root string, ok bool) {
|
|
dir := filepath.Dir(path)
|
|
|
|
// Climb to the nearest ancestor holding audio files directly.
|
|
candidate := dir
|
|
for directAudioCount(candidate) == 0 {
|
|
parent := filepath.Dir(candidate)
|
|
if parent == candidate || candidate == libraryRoot || !strings.HasPrefix(candidate, libraryRoot) {
|
|
return "", false
|
|
}
|
|
candidate = parent
|
|
}
|
|
|
|
// Merge upward through disc/part groupings. Stop at the first
|
|
// directory that doesn't look like a disc marker, or whose parent
|
|
// holds audio of its own (that would swallow sibling books).
|
|
for discPartPattern.MatchString(strings.ToLower(filepath.Base(candidate))) {
|
|
parent := filepath.Dir(candidate)
|
|
if parent == candidate || candidate == libraryRoot || !strings.HasPrefix(candidate, libraryRoot) {
|
|
break
|
|
}
|
|
if directAudioCount(parent) > 0 {
|
|
break
|
|
}
|
|
candidate = parent
|
|
}
|
|
|
|
return candidate, true
|
|
}
|
|
|
|
// bookRootForTrackFile resolves the folder-book root for an audio file.
|
|
// ok is false when the resolved root holds fewer than two tracks — the
|
|
// caller falls back to the single-file ingestion path.
|
|
func bookRootForTrackFile(path, libraryRoot string) (root string, tracks []string, ok bool) {
|
|
candidate, ok := bookRootDirForTrackFile(path, libraryRoot)
|
|
if !ok {
|
|
return "", nil, false
|
|
}
|
|
tracks = subtreeAudioTracks(candidate)
|
|
return candidate, tracks, len(tracks) >= 2
|
|
}
|
|
|
|
// maybeProcessAudioBook resolves the owning library + root folder and
|
|
// hands an audio file to processAudioBookFile. Called from the scan walk
|
|
// BEFORE the single-file path; handled=true consumes the file.
|
|
func (s *MediaScanner) maybeProcessAudioBook(ctx context.Context, path string) (bool, error) {
|
|
if !isAudioFile(path) {
|
|
return false, nil
|
|
}
|
|
// Extension allowlisting already scopes audio to audiobook libraries,
|
|
// so any scannable audio file belongs to one.
|
|
rootFolder := ""
|
|
for _, folder := range s.folders {
|
|
if strings.HasPrefix(path, folder) {
|
|
rootFolder = folder
|
|
break
|
|
}
|
|
}
|
|
if rootFolder == "" {
|
|
return false, nil
|
|
}
|
|
|
|
libraryID := s.defaultLibraryID
|
|
if !libraryID.Valid {
|
|
lib, err := s.db.GetLibraryByFolder(ctx, rootFolder)
|
|
if err != nil {
|
|
return false, fmt.Errorf("failed to find library for audio file %s: %w", path, err)
|
|
}
|
|
libraryID = lib.LibraryID
|
|
}
|
|
return s.processAudioBookFile(ctx, path, libraryID, rootFolder)
|
|
}
|
|
|
|
// processAudioBookFile dispatches one audio file: zips become zip books,
|
|
// other audio resolves its folder-book root (falls back to the caller's
|
|
// single-file path when the root holds fewer than two tracks).
|
|
func (s *MediaScanner) processAudioBookFile(ctx context.Context, path string, libraryID pgtype.UUID, rootFolder string) (bool, error) {
|
|
if strings.EqualFold(filepath.Ext(path), ".zip") {
|
|
if err := s.processZipAudioBook(ctx, path, libraryID, rootFolder); err != nil {
|
|
return true, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
root, ok := bookRootDirForTrackFile(path, rootFolder)
|
|
if !ok {
|
|
return false, nil // no audio-bearing ancestor: shouldn't happen
|
|
}
|
|
if s.audioBookRoots[root] {
|
|
return true, nil // already ingested this walk (≥2 verified then)
|
|
}
|
|
|
|
tracks := subtreeAudioTracks(root)
|
|
if len(tracks) < 2 {
|
|
return false, nil // lone file: single-file book, existing path
|
|
}
|
|
s.audioBookRoots[root] = true
|
|
|
|
if err := s.processAudioFolderBook(ctx, root, tracks, libraryID, rootFolder); err != nil {
|
|
return true, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// audioTrackInfo is the probed fact set for one track file.
|
|
type audioTrackInfo struct {
|
|
relPath string // library-relative (folder books) or entry name (zip books)
|
|
title string
|
|
duration float64
|
|
size int64
|
|
disc int // tag disc number (TPOS/disk); 0 = absent
|
|
trackNum int // tag track number (TRCK/trkn); 0 = absent
|
|
}
|
|
|
|
// parseTagOrdinal reads "N" or "N/M" tag values (disc, track) into N.
|
|
func parseTagOrdinal(v string) int {
|
|
v = strings.TrimSpace(v)
|
|
if idx := strings.Index(v, "/"); idx != -1 {
|
|
v = v[:idx]
|
|
}
|
|
n, err := strconv.Atoi(strings.TrimSpace(v))
|
|
if err != nil || n < 0 {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
// applyTrackProbe fills one track's probed facts from its ffprobe
|
|
// report: duration, disc/track ordering keys, and the display title per
|
|
// the precedence chain (movement_name → title → caller's fallback).
|
|
func applyTrackProbe(report *ffprobeReport, info *audioTrackInfo) {
|
|
if report == nil {
|
|
return
|
|
}
|
|
info.duration = float64(report.Format.Duration)
|
|
info.disc = parseTagOrdinal(report.tag("disc"))
|
|
info.trackNum = parseTagOrdinal(report.tag("track"))
|
|
if t := report.tag("movement_name"); t != "" {
|
|
info.title = t
|
|
} else if t := report.tag("title"); t != "" {
|
|
info.title = t
|
|
}
|
|
}
|
|
|
|
// orderTracksByTags reorders probed tracks by (disc, track) when EVERY
|
|
// track carries a track number — tag order then outranks filename
|
|
// guesses for multi-disc rips whose files are flat `01.mp3` dumps. Any
|
|
// gap in coverage falls back to the natural-sorted discovery order.
|
|
func orderTracksByTags(probed []audioTrackInfo) {
|
|
if len(probed) < 2 {
|
|
return
|
|
}
|
|
for _, tr := range probed {
|
|
if tr.trackNum <= 0 {
|
|
return
|
|
}
|
|
}
|
|
sort.SliceStable(probed, func(x, y int) bool {
|
|
if probed[x].disc != probed[y].disc {
|
|
return probed[x].disc < probed[y].disc
|
|
}
|
|
if probed[x].trackNum != probed[y].trackNum {
|
|
return probed[x].trackNum < probed[y].trackNum
|
|
}
|
|
return naturalLess(probed[x].relPath, probed[y].relPath)
|
|
})
|
|
}
|
|
|
|
// playlistHint is the ordering/title intent of an m3u/m3u8 playlist in a
|
|
// book root — a curator's explicit track order and names.
|
|
type playlistHint struct {
|
|
refs []string // entry refs in playlist order (slash-normalized)
|
|
titles map[string]string // normalized ref → #EXTINF title
|
|
}
|
|
|
|
// findPlaylistHint parses THE playlist of a book root (exactly one
|
|
// .m3u/.m3u8 among its direct children); zero or several → no hint.
|
|
func findPlaylistHint(rootDir string) *playlistHint {
|
|
matches, err := filepath.Glob(filepath.Join(rootDir, "*.m3u"))
|
|
if err != nil {
|
|
matches = nil
|
|
}
|
|
m8, err := filepath.Glob(filepath.Join(rootDir, "*.m3u8"))
|
|
if err == nil {
|
|
matches = append(matches, m8...)
|
|
}
|
|
if len(matches) != 1 {
|
|
return nil
|
|
}
|
|
|
|
f, err := os.Open(matches[0])
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
|
|
hint := &playlistHint{titles: map[string]string{}}
|
|
pendingTitle := ""
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "#") {
|
|
if strings.HasPrefix(strings.ToUpper(line), "#EXTINF:") {
|
|
if idx := strings.Index(line, ","); idx != -1 {
|
|
pendingTitle = strings.TrimSpace(line[idx+1:])
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
ref := filepath.ToSlash(line)
|
|
ref = strings.TrimPrefix(ref, "./")
|
|
hint.refs = append(hint.refs, ref)
|
|
if pendingTitle != "" {
|
|
hint.titles[strings.ToLower(ref)] = pendingTitle
|
|
}
|
|
pendingTitle = ""
|
|
}
|
|
if len(hint.refs) == 0 {
|
|
return nil
|
|
}
|
|
return hint
|
|
}
|
|
|
|
// reorder returns the absolute track paths in playlist order (refs
|
|
// matched by relative path first, then by basename), with any tracks
|
|
// the playlist didn't mention appended in their natural order.
|
|
func (h *playlistHint) reorder(absTracks []string) []string {
|
|
byRel := make(map[string]string, len(absTracks))
|
|
byBase := make(map[string][]string, len(absTracks))
|
|
for _, abs := range absTracks {
|
|
rel := filepath.ToSlash(abs)
|
|
byRel[strings.ToLower(rel)] = abs
|
|
byBase[strings.ToLower(filepath.Base(abs))] = append(byBase[strings.ToLower(filepath.Base(abs))], abs)
|
|
}
|
|
|
|
ordered := make([]string, 0, len(absTracks))
|
|
used := make(map[string]bool, len(absTracks))
|
|
for _, ref := range h.refs {
|
|
abs, ok := byRel[strings.ToLower(ref)]
|
|
if !ok {
|
|
if candidates := byBase[strings.ToLower(filepath.Base(ref))]; len(candidates) > 0 {
|
|
abs = candidates[0]
|
|
ok = true
|
|
}
|
|
}
|
|
if ok && !used[abs] {
|
|
ordered = append(ordered, abs)
|
|
used[abs] = true
|
|
}
|
|
}
|
|
for _, abs := range absTracks {
|
|
if !used[abs] {
|
|
ordered = append(ordered, abs)
|
|
}
|
|
}
|
|
return ordered
|
|
}
|
|
|
|
// titleFor returns the playlist's #EXTINF title for an absolute track
|
|
// path (matched by relative path, then basename); "" when none.
|
|
func (h *playlistHint) titleFor(abs string) string {
|
|
if h == nil {
|
|
return ""
|
|
}
|
|
if t, ok := h.titles[strings.ToLower(filepath.ToSlash(abs))]; ok {
|
|
return t
|
|
}
|
|
return h.titles[strings.ToLower(filepath.Base(abs))]
|
|
}
|
|
|
|
// cleanTrackTitle derives a display title from a filename when the track
|
|
// carries no tag: strips extension, leading track numbers and separators.
|
|
func cleanTrackTitle(path string) string {
|
|
base := filepath.Base(path)
|
|
title := strings.TrimSpace(strings.TrimSuffix(base, filepath.Ext(base)))
|
|
trimmed := strings.TrimLeft(title, "0123456789")
|
|
if trimmed != title && trimmed != "" {
|
|
title = strings.TrimLeft(trimmed, " -_.")
|
|
}
|
|
if title == "" {
|
|
title = base
|
|
}
|
|
return title
|
|
}
|
|
|
|
// buildMultiFileAudioPayload assembles the audio_metadata payload for a
|
|
// multi-file book: layout, cumulative track start/end, chapters (sidecar
|
|
// chapters win; otherwise the track boundaries ARE the chapters) and the
|
|
// aggregate duration.
|
|
func buildMultiFileAudioPayload(layout string, payload *AudioMetadataPayload, tracks []audioTrackInfo) *AudioMetadataPayload {
|
|
if payload == nil {
|
|
payload = &AudioMetadataPayload{}
|
|
}
|
|
payload.Layout = layout
|
|
payload.Tracks = nil
|
|
clock := 0.0
|
|
for i, tr := range tracks {
|
|
end := clock + tr.duration
|
|
payload.Tracks = append(payload.Tracks, AudioTrack{
|
|
Index: i,
|
|
Path: tr.relPath,
|
|
Title: tr.title,
|
|
Start: clock,
|
|
End: end,
|
|
DurationSeconds: tr.duration,
|
|
SizeBytes: tr.size,
|
|
})
|
|
clock = end
|
|
}
|
|
payload.DurationSeconds = clock
|
|
|
|
// Curated sidecar chapters describe the whole book in global seconds
|
|
// and keep priority; otherwise tracks are the chapters (the
|
|
// documented folder-as-book shape: same {index,title,start,end}).
|
|
if len(payload.Chapters) == 0 && len(payload.Tracks) > 0 {
|
|
for _, tr := range payload.Tracks {
|
|
payload.Chapters = append(payload.Chapters, AudioChapter{
|
|
Index: tr.Index,
|
|
Title: tr.Title,
|
|
Start: tr.Start,
|
|
End: tr.End,
|
|
})
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
// extractAudioBookMetadata gathers book-level metadata for a multi-file
|
|
// book: sidecars next to the representative path (a sentinel inside the
|
|
// book root for folders, the zip itself for archives — the sidecar checks
|
|
// all look at Dir(representative)), then folder-structure patterns, then
|
|
// the first track's tags (album/artist/composer/grouping/freeform atoms
|
|
// via applyAudioTagFallbacks), then fallbackTitle (the book directory
|
|
// name for folder books, the zip name for zip books — passed explicitly
|
|
// because Dir(representative) means different things per layout).
|
|
// Embedded cover art is the cover fallback of last resort: sidecar
|
|
// covers win. firstTrackAbs is the first track's absolute path for
|
|
// folder books ("" for zip books — their first entry is temp-extracted
|
|
// on demand).
|
|
func (s *MediaScanner) extractAudioBookMetadata(representative, rootFolder string, firstReport *ffprobeReport, firstTrackAbs, fallbackTitle string) *MediaMetadata {
|
|
extracted, err := s.extractMetadata(representative)
|
|
var metadata *MediaMetadata
|
|
if err != nil || extracted == nil {
|
|
metadata = &MediaMetadata{}
|
|
} else {
|
|
metadata = extracted
|
|
}
|
|
// The folder-book representative is a sentinel INSIDE the root (the
|
|
// sidecar checks all look at Dir(representative)). The generic
|
|
// extractor's filename-title fallback would name the book
|
|
// ".audiobook-root" and every real source below would see a non-empty
|
|
// title — strip it so sidecars, folder structure, tags and finally
|
|
// the directory name get their turn.
|
|
if metadata.Title != "" && strings.HasPrefix(filepath.Base(representative), ".") &&
|
|
metadata.Title == strings.TrimSuffix(filepath.Base(representative), filepath.Ext(representative)) {
|
|
metadata.Title = ""
|
|
}
|
|
if metadata.AudioMeta == nil {
|
|
metadata.AudioMeta = readSidecarAudio(representative)
|
|
}
|
|
|
|
if folderMetadata := s.extractFolderStructureMetadata(representative, rootFolder); folderMetadata != nil {
|
|
if metadata.Title == "" && folderMetadata.Title != "" {
|
|
metadata.Title = folderMetadata.Title
|
|
}
|
|
if metadata.Author == "" && folderMetadata.Author != "" {
|
|
metadata.Author = folderMetadata.Author
|
|
}
|
|
if metadata.Series == "" && folderMetadata.Series != "" {
|
|
metadata.Series = folderMetadata.Series
|
|
}
|
|
if metadata.SeriesNumber == 0 && folderMetadata.SeriesNumber > 0 {
|
|
metadata.SeriesNumber = folderMetadata.SeriesNumber
|
|
}
|
|
}
|
|
|
|
// Tag fallbacks run after sidecars and folder structure — the shared
|
|
// helper fills title/author/narrators/series/date/genre from the
|
|
// first track's tags (album/artist/composer/grouping/date/genre plus
|
|
// Audible freeform atoms), then embedded cover art as the cover
|
|
// fallback of last resort.
|
|
applyAudioTagFallbacks(metadata, firstReport)
|
|
|
|
if metadata.Title == "" && fallbackTitle != "" {
|
|
metadata.Title = strings.ReplaceAll(fallbackTitle, "_", " ")
|
|
}
|
|
if metadata.Author == "" {
|
|
metadata.Author = "Unknown"
|
|
}
|
|
if metadata.CoverPath == "" {
|
|
if cover := findSidecarCover(representative); cover != "" {
|
|
metadata.CoverPath = s.getRelativePath(cover)
|
|
}
|
|
}
|
|
if metadata.CoverPath == "" && firstTrackAbs != "" {
|
|
if cover := extractEmbeddedAudioCover(firstTrackAbs, representative); cover != "" {
|
|
metadata.CoverPath = s.getRelativePath(cover)
|
|
}
|
|
}
|
|
return metadata
|
|
}
|
|
|
|
// createAudioBookItem inserts the media_items row shared by folder and
|
|
// zip books. The hash columns stay NULL — content dedup is path-based for
|
|
// multi-file books (there is no single byte stream to hash).
|
|
func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype.UUID, metadata *MediaMetadata, relPath string, totalSize int64, mimeType string, modTime pgtype.Timestamptz) (database.MediaItems, error) {
|
|
return s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
|
LibraryID: libraryID,
|
|
Title: metadata.Title,
|
|
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
|
|
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
|
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
|
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
|
FilePath: relPath,
|
|
FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
|
|
MimeType: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
|
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
|
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
|
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
|
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
|
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
|
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
|
Contributors: utils.NormalizeContributors(metadata.Contributors),
|
|
Narrators: nilToEmpty(metadata.Narrators),
|
|
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
|
Abridged: metadata.Abridged,
|
|
Tags: utils.NormalizeTags(metadata.Tags),
|
|
AddedByAdminID: s.adminID,
|
|
CreatedAt: modTime,
|
|
ImportedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
|
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
|
|
})
|
|
}
|
|
|
|
// setAudioFormatGroup writes the audiobook format facts after create.
|
|
func (s *MediaScanner) setAudioFormatGroup(ctx context.Context, itemID pgtype.UUID, mimeType string, chapterCount int) error {
|
|
return s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
|
|
ID: itemID,
|
|
FormatGroup: "audiobook",
|
|
FormatMimetype: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
|
IsReflowable: pgtype.Bool{Bool: false, Valid: true},
|
|
HasFixedLayout: pgtype.Bool{Bool: false, Valid: true},
|
|
ChapterCount: pgtype.Int4{Int32: int32(chapterCount), Valid: chapterCount > 0},
|
|
})
|
|
}
|
|
|
|
// audioPayloadTracksEqual reports whether the stored audio_metadata
|
|
// already describes exactly the given track manifest (paths + sizes).
|
|
// Timestamp-free manifests make rescans idempotent.
|
|
func audioPayloadTracksEqual(stored []byte, payload *AudioMetadataPayload) bool {
|
|
if len(stored) == 0 {
|
|
return false
|
|
}
|
|
var storedPayload AudioMetadataPayload
|
|
if err := json.Unmarshal(stored, &storedPayload); err != nil {
|
|
return false
|
|
}
|
|
if storedPayload.Layout != payload.Layout || len(storedPayload.Tracks) != len(payload.Tracks) {
|
|
return false
|
|
}
|
|
for i, tr := range storedPayload.Tracks {
|
|
if tr.Path != payload.Tracks[i].Path || tr.SizeBytes != payload.Tracks[i].SizeBytes {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// absorbOneTrackItems removes the one-track audiobook items a previous
|
|
// scan ingested individually from the tracks now grouped under a folder
|
|
// book root. Annotations/progress cascade with the rows, and multi-file
|
|
// sources never carried listening progress — nothing real is lost. Only
|
|
// audio files under the root are deleted; real books and the folder book
|
|
// row itself are never touched.
|
|
func (s *MediaScanner) absorbOneTrackItems(ctx context.Context, libraryID pgtype.UUID, rootRel string) {
|
|
items, err := s.db.ListMediaItemsByLibraryIncludingArchived(ctx, libraryID)
|
|
if err != nil {
|
|
fmt.Printf("Warning: absorb pass could not list library items for %s: %v\n", rootRel, err)
|
|
return
|
|
}
|
|
prefix := rootRel + "/"
|
|
removed := 0
|
|
for _, item := range items {
|
|
if !strings.HasPrefix(item.FilePath, prefix) || !isAudioTrackFile(item.FilePath) {
|
|
continue
|
|
}
|
|
if err := s.db.DeleteMediaItem(ctx, item.ID); err != nil {
|
|
fmt.Printf("Warning: failed to absorb one-track item %s into folder book: %v\n", item.FilePath, err)
|
|
continue
|
|
}
|
|
fmt.Printf("[AUDIO-MERGE] Absorbed one-track item '%s' into folder book %s\n", item.Title, rootRel)
|
|
s.logger.LogDelete(fmt.Sprintf("[AUDIO-MERGE] Absorbed one-track item '%s' into folder book '%s'", item.Title, rootRel))
|
|
removed++
|
|
}
|
|
if removed > 0 {
|
|
fmt.Printf("[AUDIO-MERGE] %d one-track item(s) absorbed into %s\n", removed, rootRel)
|
|
}
|
|
}
|
|
|
|
// audioLayoutOf reads the layout key from a stored audio_metadata
|
|
// payload; "" when absent (single-file book or not audio).
|
|
func audioLayoutOf(stored []byte) string {
|
|
if len(stored) == 0 {
|
|
return ""
|
|
}
|
|
var probe struct {
|
|
Layout string `json:"layout"`
|
|
}
|
|
if err := json.Unmarshal(stored, &probe); err != nil {
|
|
return ""
|
|
}
|
|
return probe.Layout
|
|
}
|
|
|
|
// rescanMultiFileAudioBook is the per-item rescan for folder/zip books:
|
|
// rebuild the track manifest from disk and re-derive book metadata
|
|
// (sidecars, folder structure, first-track tags), then write through the
|
|
// shared editorial updater so override semantics match single-file
|
|
// books. The generic per-file updater is bypassed by the caller — see
|
|
// RescanMediaItem.
|
|
func (s *MediaScanner) rescanMultiFileAudioBook(ctx context.Context, item database.MediaItems, fullPath string) error {
|
|
layout := audioLayoutOf(item.AudioMetadata)
|
|
|
|
var representative string
|
|
var probed []audioTrackInfo
|
|
switch layout {
|
|
case "folder":
|
|
tracks := subtreeAudioTracks(fullPath)
|
|
if len(tracks) == 0 {
|
|
return fmt.Errorf("folder book %s has no tracks on disk", item.FilePath)
|
|
}
|
|
// Sentinel INSIDE the root: sidecar checks look at Dir().
|
|
representative = filepath.Join(fullPath, ".audiobook-root")
|
|
for _, abs := range tracks {
|
|
info := audioTrackInfo{
|
|
relPath: s.getRelativePath(abs),
|
|
title: cleanTrackTitle(abs),
|
|
}
|
|
if st, err := os.Stat(abs); err == nil {
|
|
info.size = st.Size()
|
|
}
|
|
if report := runFFProbe(abs); report != nil {
|
|
applyTrackProbe(report, &info)
|
|
}
|
|
probed = append(probed, info)
|
|
}
|
|
case "zip":
|
|
representative = fullPath
|
|
entries, err := zipAudioEntries(fullPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read audio zip %s: %w", fullPath, err)
|
|
}
|
|
for _, e := range entries {
|
|
info := audioTrackInfo{
|
|
relPath: e.name,
|
|
title: cleanTrackTitle(e.name),
|
|
size: e.size,
|
|
}
|
|
if report := probeZipEntry(fullPath, e.name); report != nil {
|
|
applyTrackProbe(report, &info)
|
|
}
|
|
probed = append(probed, info)
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown audio layout %q for %s", layout, item.FilePath)
|
|
}
|
|
if len(probed) == 0 {
|
|
return fmt.Errorf("multi-file book %s has no tracks", item.FilePath)
|
|
}
|
|
orderTracksByTags(probed)
|
|
|
|
// First track's tags name the book when sidecars/folders don't.
|
|
var firstReport *ffprobeReport
|
|
switch layout {
|
|
case "folder":
|
|
firstReport = runFFProbe(fullPath + string(filepath.Separator) + probed[0].relPath)
|
|
case "zip":
|
|
firstReport = probeZipEntry(fullPath, probed[0].relPath)
|
|
}
|
|
|
|
rootFolder := ""
|
|
for _, folder := range s.folders {
|
|
if strings.HasPrefix(fullPath, folder) {
|
|
rootFolder = folder
|
|
break
|
|
}
|
|
}
|
|
|
|
metadata := s.extractAudioBookMetadata(representative, rootFolder, firstReport, firstTrackAbsFor(layout, fullPath, probed), layoutFallbackTitle(layout, fullPath))
|
|
payload := buildMultiFileAudioPayload(layout, metadata.AudioMeta, probed)
|
|
metadata.AudioMeta = payload
|
|
if len(payload.Narrators) > 0 && len(metadata.Narrators) == 0 {
|
|
metadata.Narrators = payload.Narrators
|
|
}
|
|
if payload.ASIN != "" && metadata.ASIN == "" {
|
|
metadata.ASIN = payload.ASIN
|
|
}
|
|
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
|
|
metadata.Tags = utils.NormalizeTags(metadata.Tags)
|
|
|
|
if err := s.updateMediaItemWithMetadata(ctx, item, representative, metadata); err != nil {
|
|
return fmt.Errorf("failed to update multi-file book %s: %w", item.FilePath, err)
|
|
}
|
|
|
|
// file_size/chapter_count ride the focused payload query (the generic
|
|
// update doesn't carry file_size).
|
|
var totalSize int64
|
|
for _, tr := range probed {
|
|
totalSize += tr.size
|
|
}
|
|
firstMime := mimeForAudioEntry(probed[0].relPath)
|
|
if layout == "folder" {
|
|
firstMime = s.getMimeType(fullPath + string(filepath.Separator) + probed[0].relPath)
|
|
}
|
|
if _, err := s.db.UpdateMediaItemAudioBookPayload(ctx, database.UpdateMediaItemAudioBookPayloadParams{
|
|
ID: item.ID,
|
|
FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
|
|
MimeType: pgtype.Text{String: firstMime, Valid: firstMime != ""},
|
|
AudioMetadata: marshalAudioMetadata(payload),
|
|
ChapterCount: pgtype.Int4{Int32: int32(len(payload.Chapters)), Valid: len(payload.Chapters) > 0},
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to refresh audio book payload %s: %w", item.FilePath, err)
|
|
}
|
|
|
|
fmt.Printf("[AUDIO] Rescanned %s book '%s' (%d tracks)\n", layout, item.Title, len(probed))
|
|
return nil
|
|
}
|
|
|
|
// layoutFallbackTitle is the last-resort book title per layout: the book
|
|
// directory name for folder books, the zip's own name (extension
|
|
// stripped) for zip books.
|
|
func layoutFallbackTitle(layout, fullPath string) string {
|
|
if layout == "zip" {
|
|
return strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
|
|
}
|
|
return filepath.Base(fullPath)
|
|
}
|
|
|
|
// firstTrackAbsFor returns the first track's absolute path for folder
|
|
// books (zip books carry in-archive entries — no absolute path).
|
|
func firstTrackAbsFor(layout, rootPath string, probed []audioTrackInfo) string {
|
|
if layout == "folder" && len(probed) > 0 {
|
|
return filepath.Join(rootPath, probed[0].relPath)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// audioTrackCountOf reads the stored tracks-array length from an
|
|
// audio_metadata payload; -1 when absent/unparseable.
|
|
func audioTrackCountOf(stored []byte) int {
|
|
if len(stored) == 0 {
|
|
return -1
|
|
}
|
|
var probe struct {
|
|
Tracks []json.RawMessage `json:"tracks"`
|
|
}
|
|
if err := json.Unmarshal(stored, &probe); err != nil {
|
|
return -1
|
|
}
|
|
return len(probe.Tracks)
|
|
}
|
|
|
|
// multiFileBookUnchanged is the rescan fast path: track count and total
|
|
// size match the stored row AND the stored manifest carries real
|
|
// durations, so the (expensive) per-track ffprobe pass is skipped. A
|
|
// zero book duration means the last scan ran without ffprobe — treat it
|
|
// as changed so a ffprobe-equipped rescan can fill durations in (the
|
|
// probe pass is nearly free in ffprobe-less environments anyway).
|
|
// Book-level metadata is not re-derived on unchanged scans — a
|
|
// metadata.json edit needs a force rescan or a track change to pick up,
|
|
// same tradeoff the single-file path makes on size-unchanged skips.
|
|
func multiFileBookUnchanged(existing database.MediaItems, totalSize int64, trackCount int) bool {
|
|
if existing.FileSize.Int64 != totalSize {
|
|
return false
|
|
}
|
|
if audioTrackCountOf(existing.AudioMetadata) != trackCount {
|
|
return false
|
|
}
|
|
var probe struct {
|
|
DurationSeconds float64 `json:"duration_seconds"`
|
|
}
|
|
if len(existing.AudioMetadata) > 0 {
|
|
_ = json.Unmarshal(existing.AudioMetadata, &probe)
|
|
}
|
|
return probe.DurationSeconds > 0
|
|
}
|
|
|
|
// processAudioFolderBook ingests or refreshes one folder-of-tracks book.
|
|
// The item's file_path is the book root directory (library-relative);
|
|
// file_size is the track-size sum; audio_metadata carries layout "folder"
|
|
// plus the track manifest.
|
|
func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir string, tracks []string, libraryID pgtype.UUID, rootFolder string) error {
|
|
rootRel := s.getRelativePath(rootDir)
|
|
fmt.Printf("[AUDIO] Folder book at %s (%d tracks)\n", rootRel, len(tracks))
|
|
|
|
// Cheap pass first: track sizes only. The per-track ffprobe pass
|
|
// below is expensive (a 1000-track book must not re-probe on every
|
|
// auto-scan), so the existence check runs before it.
|
|
var totalSize int64
|
|
for _, abs := range tracks {
|
|
if st, err := os.Stat(abs); err == nil {
|
|
totalSize += st.Size()
|
|
}
|
|
}
|
|
|
|
existing, existingErr := s.getMediaItemByFilePath(ctx, rootRel, libraryID)
|
|
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(tracks)) {
|
|
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
|
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
|
fmt.Printf("Warning: failed to unarchive folder book %s: %v\n", rootRel, err)
|
|
} else {
|
|
fmt.Printf("[AUDIO] Restored folder book '%s' (root is back)\n", existing.Title)
|
|
}
|
|
}
|
|
// One-track strays from pre-grouping scans go away either way.
|
|
s.absorbOneTrackItems(ctx, libraryID, rootRel)
|
|
return nil
|
|
}
|
|
|
|
// Playlist hint: a lone m3u/m3u8 in the root is a curator's explicit
|
|
// order (and #EXTINF names) for the track list.
|
|
hint := findPlaylistHint(rootDir)
|
|
if hint != nil {
|
|
tracks = hint.reorder(tracks)
|
|
fmt.Printf("[AUDIO] Playlist ordering applied to %s (%d refs)\n", rootRel, len(hint.refs))
|
|
}
|
|
|
|
probed := make([]audioTrackInfo, 0, len(tracks))
|
|
var firstReport *ffprobeReport
|
|
for i, abs := range tracks {
|
|
info := audioTrackInfo{
|
|
relPath: s.getRelativePath(abs),
|
|
title: cleanTrackTitle(abs),
|
|
}
|
|
if t := hint.titleFor(abs); t != "" {
|
|
info.title = t
|
|
}
|
|
if st, err := os.Stat(abs); err == nil {
|
|
info.size = st.Size()
|
|
}
|
|
report := runFFProbe(abs)
|
|
if report != nil {
|
|
applyTrackProbe(report, &info)
|
|
if i == 0 {
|
|
firstReport = report
|
|
}
|
|
}
|
|
probed = append(probed, info)
|
|
}
|
|
if len(probed) == 0 {
|
|
return fmt.Errorf("folder book %s has no probeable tracks", rootRel)
|
|
}
|
|
orderTracksByTags(probed)
|
|
|
|
// The representative path drives the sidecar/folder-structure checks;
|
|
// they all look at Dir(representative), so a sentinel INSIDE the root
|
|
// resolves covers and metadata.json living there (including disc-
|
|
// layout roots where track zero sits deeper).
|
|
sentinel := filepath.Join(rootDir, ".audiobook-root")
|
|
metadata := s.extractAudioBookMetadata(sentinel, rootFolder, firstReport, tracks[0], filepath.Base(rootDir))
|
|
payload := buildMultiFileAudioPayload("folder", metadata.AudioMeta, probed)
|
|
metadata.AudioMeta = payload
|
|
if len(payload.Narrators) > 0 && len(metadata.Narrators) == 0 {
|
|
metadata.Narrators = payload.Narrators
|
|
}
|
|
if payload.ASIN != "" && metadata.ASIN == "" {
|
|
metadata.ASIN = payload.ASIN
|
|
}
|
|
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
|
|
metadata.Tags = utils.NormalizeTags(metadata.Tags)
|
|
|
|
firstMime := s.getMimeType(tracks[0])
|
|
var modTime pgtype.Timestamptz
|
|
if rootInfo, err := os.Stat(rootDir); err == nil {
|
|
modTime = pgtype.Timestamptz{Time: rootInfo.ModTime(), Valid: true}
|
|
}
|
|
|
|
err := existingErr
|
|
switch {
|
|
case err == nil:
|
|
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload)
|
|
if changed || s.forceRescan {
|
|
if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
|
|
return err
|
|
}
|
|
} else if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
|
// Unchanged content that was archived while the root was gone.
|
|
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
|
fmt.Printf("Warning: failed to unarchive folder book %s: %v\n", rootRel, err)
|
|
} else {
|
|
fmt.Printf("[AUDIO] Restored folder book '%s' (root is back)\n", existing.Title)
|
|
}
|
|
}
|
|
case errors.Is(err, pgx.ErrNoRows):
|
|
created, err := s.createAudioBookItem(ctx, libraryID, metadata, rootRel, totalSize, firstMime, modTime)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create folder book %s: %w", rootRel, err)
|
|
}
|
|
if err := s.setAudioFormatGroup(ctx, created.ID, firstMime, len(payload.Chapters)); err != nil {
|
|
fmt.Printf("Warning: failed to set format group for folder book %s: %v\n", rootRel, err)
|
|
}
|
|
fmt.Printf("[AUDIO] Created folder book '%s' (%d tracks) at %s\n", metadata.Title, len(tracks), rootRel)
|
|
s.newItems++
|
|
default:
|
|
return fmt.Errorf("failed to check folder book existence %s: %w", rootRel, err)
|
|
}
|
|
|
|
// One-track strays from pre-grouping scans go away either way.
|
|
s.absorbOneTrackItems(ctx, libraryID, rootRel)
|
|
return nil
|
|
}
|
|
|
|
// updateAudioBookItem refreshes an existing folder/zip book row after a
|
|
// rescan saw the track set change (or the admin forced a rescan). Only
|
|
// scan-derived fields move — editorial columns keep admin edits.
|
|
func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing database.MediaItems, payload *AudioMetadataPayload, mimeType string, totalSize int64, chapterCount int) error {
|
|
if _, err := s.db.UpdateMediaItemAudioBookPayload(ctx, database.UpdateMediaItemAudioBookPayloadParams{
|
|
ID: existing.ID,
|
|
FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
|
|
MimeType: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
|
AudioMetadata: marshalAudioMetadata(payload),
|
|
ChapterCount: pgtype.Int4{Int32: int32(chapterCount), Valid: chapterCount > 0},
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to update audio book %s: %w", existing.FilePath, err)
|
|
}
|
|
fmt.Printf("[AUDIO] Refreshed multi-file book '%s' (%d tracks)\n", existing.Title, len(payload.Tracks))
|
|
return nil
|
|
}
|
|
|
|
// processZipAudioBook ingests a zip archive of audio tracks as ONE book.
|
|
// The zip is the item's file (downloadable like any single file);
|
|
// audio_metadata carries layout "zip" and per-entry track paths the
|
|
// streaming endpoint extracts on demand.
|
|
func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string, libraryID pgtype.UUID, rootFolder string) error {
|
|
entries, err := zipAudioEntries(zipPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read audio zip %s: %w", zipPath, err)
|
|
}
|
|
if len(entries) == 0 {
|
|
fmt.Printf("[AUDIO] Skipping %s: zip contains no audio tracks\n", zipPath)
|
|
return nil
|
|
}
|
|
|
|
zipRel := s.getRelativePath(zipPath)
|
|
fmt.Printf("[AUDIO] Zip book at %s (%d tracks)\n", zipRel, len(entries))
|
|
|
|
// Cheap pass first: central-directory sizes only. The per-entry
|
|
// extract+ffprobe pass is expensive and skipped when the manifest is
|
|
// unchanged (see multiFileBookUnchanged).
|
|
var totalSize int64
|
|
for _, entry := range entries {
|
|
totalSize += entry.size
|
|
}
|
|
|
|
existing, existingErr := s.getMediaItemByFilePath(ctx, zipRel, libraryID)
|
|
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(entries)) {
|
|
return nil
|
|
}
|
|
|
|
probed := make([]audioTrackInfo, 0, len(entries))
|
|
var firstReport *ffprobeReport
|
|
for i, entry := range entries {
|
|
info := audioTrackInfo{
|
|
relPath: entry.name,
|
|
title: cleanTrackTitle(entry.name),
|
|
size: entry.size,
|
|
}
|
|
report := probeZipEntry(zipPath, entry.name)
|
|
if report != nil {
|
|
applyTrackProbe(report, &info)
|
|
if i == 0 {
|
|
firstReport = report
|
|
}
|
|
}
|
|
probed = append(probed, info)
|
|
}
|
|
orderTracksByTags(probed)
|
|
|
|
metadata := s.extractAudioBookMetadata(zipPath, rootFolder, firstReport, "", layoutFallbackTitle("zip", zipPath))
|
|
if metadata.CoverPath == "" && len(entries) > 0 {
|
|
// Zip books keep their art as the zip's sibling "<zip>.cover.jpg";
|
|
// the first entry is temp-extracted for ffmpeg to read.
|
|
if tmp, cleanup, err := zipEntryToTemp(zipPath, entries[0].name); err == nil {
|
|
if cover := extractEmbeddedAudioCover(tmp, zipPath); cover != "" {
|
|
metadata.CoverPath = s.getRelativePath(cover)
|
|
}
|
|
cleanup()
|
|
}
|
|
}
|
|
payload := buildMultiFileAudioPayload("zip", metadata.AudioMeta, probed)
|
|
metadata.AudioMeta = payload
|
|
if len(payload.Narrators) > 0 && len(metadata.Narrators) == 0 {
|
|
metadata.Narrators = payload.Narrators
|
|
}
|
|
if payload.ASIN != "" && metadata.ASIN == "" {
|
|
metadata.ASIN = payload.ASIN
|
|
}
|
|
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
|
|
metadata.Tags = utils.NormalizeTags(metadata.Tags)
|
|
|
|
firstMime := mimeForAudioEntry(entries[0].name)
|
|
var modTime pgtype.Timestamptz
|
|
if info, err := os.Stat(zipPath); err == nil {
|
|
modTime = pgtype.Timestamptz{Time: info.ModTime(), Valid: true}
|
|
}
|
|
|
|
err = existingErr
|
|
switch {
|
|
case err == nil:
|
|
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload)
|
|
if changed || s.forceRescan {
|
|
if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
case errors.Is(err, pgx.ErrNoRows):
|
|
created, err := s.createAudioBookItem(ctx, libraryID, metadata, zipRel, totalSize, firstMime, modTime)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create zip book %s: %w", zipRel, err)
|
|
}
|
|
if err := s.setAudioFormatGroup(ctx, created.ID, firstMime, len(payload.Chapters)); err != nil {
|
|
fmt.Printf("Warning: failed to set format group for zip book %s: %v\n", zipRel, err)
|
|
}
|
|
fmt.Printf("[AUDIO] Created zip book '%s' (%d tracks) at %s\n", metadata.Title, len(entries), zipRel)
|
|
s.newItems++
|
|
default:
|
|
return fmt.Errorf("failed to check zip book existence %s: %w", zipRel, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// zipAudioEntry is one audio track inside a zip book.
|
|
type zipAudioEntry struct {
|
|
name string
|
|
size int64
|
|
}
|
|
|
|
// zipAudioEntries lists the audio entries of a zip, natural-sorted,
|
|
// skipping directories and macOS/hidden cruft.
|
|
func zipAudioEntries(zipPath string) ([]zipAudioEntry, error) {
|
|
r, err := zip.OpenReader(zipPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = r.Close() }()
|
|
|
|
var entries []zipAudioEntry
|
|
for _, f := range r.File {
|
|
if f.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
name := filepath.ToSlash(f.Name)
|
|
base := strings.ToLower(filepath.Base(name))
|
|
if strings.HasPrefix(base, ".") || strings.Contains(name, "__MACOSX") {
|
|
continue
|
|
}
|
|
if !isAudioFile(name) {
|
|
continue
|
|
}
|
|
entries = append(entries, zipAudioEntry{name: name, size: int64(f.UncompressedSize64)})
|
|
}
|
|
sort.Slice(entries, func(x, y int) bool { return naturalLess(entries[x].name, entries[y].name) })
|
|
return entries, nil
|
|
}
|
|
|
|
// zipEntryToTemp extracts one zip entry to a temp file (seekable input
|
|
// for ffprobe/ffmpeg). The caller MUST run the returned cleanup.
|
|
func zipEntryToTemp(zipPath, entryName string) (string, func(), error) {
|
|
r, err := zip.OpenReader(zipPath)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
var entry *zip.File
|
|
for _, f := range r.File {
|
|
if filepath.ToSlash(f.Name) == filepath.ToSlash(entryName) {
|
|
entry = f
|
|
break
|
|
}
|
|
}
|
|
if entry == nil {
|
|
_ = r.Close()
|
|
return "", nil, fmt.Errorf("entry %q not found", entryName)
|
|
}
|
|
|
|
src, err := entry.Open()
|
|
if err != nil {
|
|
_ = r.Close()
|
|
return "", nil, err
|
|
}
|
|
|
|
tmp, err := os.CreateTemp("", "bookhoard-zipentry-*"+filepath.Ext(entryName))
|
|
if err != nil {
|
|
_ = src.Close()
|
|
_ = r.Close()
|
|
return "", nil, err
|
|
}
|
|
if _, err := io.Copy(tmp, src); err != nil {
|
|
_ = tmp.Close()
|
|
_ = src.Close()
|
|
_ = r.Close()
|
|
_ = os.Remove(tmp.Name())
|
|
return "", nil, err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
_ = src.Close()
|
|
_ = r.Close()
|
|
_ = os.Remove(tmp.Name())
|
|
return "", nil, err
|
|
}
|
|
_ = src.Close()
|
|
tmpName := tmp.Name()
|
|
cleanup := func() {
|
|
_ = r.Close()
|
|
_ = os.Remove(tmpName)
|
|
}
|
|
return tmpName, cleanup, nil
|
|
}
|
|
|
|
// probeZipEntry ffprobes one zip entry by extracting it to a temp file
|
|
// (ffprobe wants a seekable path). The temp file is always removed.
|
|
func probeZipEntry(zipPath, entryName string) *ffprobeReport {
|
|
tmp, cleanup, err := zipEntryToTemp(zipPath, entryName)
|
|
if err != nil {
|
|
fmt.Printf("Warning: zip entry probe failed for %s/%s: %v\n", zipPath, entryName, err)
|
|
return nil
|
|
}
|
|
defer cleanup()
|
|
return runFFProbe(tmp)
|
|
}
|
|
|
|
// mimeForAudioEntry maps an in-zip track name to its mime type.
|
|
func mimeForAudioEntry(name string) string {
|
|
if m := MimeTypes[strings.ToLower(filepath.Ext(name))]; m != "" {
|
|
return m
|
|
}
|
|
return "audio/mpeg"
|
|
}
|