Files
bookhoard/internal/services/media_scanner_audio_multifile.go
T
John O'Keefe 80bfc1a77b fix(scanner): re-extraction is write-after-success — no NULL audio_metadata
The 2026-10-21 wipe: a re-extraction pass NULLed audio_metadata on the
m4b audiobooks — every affected book dropped its chapters, track
manifest and duration and displayed as 1 second long (the reader also
lost its manifest, since single-file books synthesize it from the
payload).

Guards on both writers of that column:
- updateMediaItemWithMetadata (generic full-row pass): when the new
  extraction carries no audio payload, keep existing.AudioMetadata.
  Single-file m4b books route here, and a failed/absent extraction
  leaves AudioMeta nil.
- updateAudioBookItem (focused payload write): an empty payload (no
  tracks, no chapters, no duration) over a populated row is a failed
  extraction — refuse the write with a loud warning and let the next
  scan retry.

audioPayloadIsEmpty() pins the empty-payload predicate (unit-tested):
the column is only ever replaced by fresh data from a real audio
extraction, never by absence of one.
2026-10-21 20:22:57 -04:00

1369 lines
51 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/google/uuid"
"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) {
// A2c §2.7: same title/subtitle-derived series fallback as the
// single-file path — folder-of-tracks books carry the same naming.
applyTitleDerivedSeries(metadata)
created, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
LibraryID: libraryID,
Title: metadata.Title,
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
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.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
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,
Explicit: metadata.Explicit,
Tags: utils.NormalizeTags(metadata.Tags),
AddedByAdminID: s.adminID,
CreatedAt: modTime,
ImportedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
})
if err != nil {
// Ledger (Phase 11): row-less import failure keyed by
// (library, path) — the folder/zip book's row was never created.
if logErr := s.LogRowlessProcessingIssue(ctx,
uuid.UUID(libraryID.Bytes), relPath,
"import_failed", fmt.Sprintf("File failed to import: %v", err), "error"); logErr != nil {
fmt.Printf("Warning: failed to record import-failure issue for %s: %v\n", relPath, logErr)
}
return database.MediaItems{}, err
}
// Ledger self-heal (Phase 11): the book finally imports — close any
// open row-less import-failure issues keyed to this (library, path).
s.resolveIssuesForPath(ctx, libraryID, relPath)
return created, nil
}
// 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)
// The representative 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")
// Manifest-gated sidecar detection (faceted plan Phase 6): a
// size-stable folder book is changed by EDITING its sidecars —
// including on scans where the track fingerprint says "unchanged".
// The ladder is stat-only in the steady state; a detected edit falls
// through to the full probe below so the once-extracted metadata can
// feed the editorial apply.
manifestChanged := false
if existingErr == nil && !s.forceRescan {
manifestChanged = s.multifileSidecarChanged(ctx, libraryID, rootDir, sentinel, rootDir)
}
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(tracks)) && !manifestChanged {
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 sentinel is defined above, next to the manifest check that
// shares it; the probe pass reuses it as the extraction
// representative.
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 || manifestChanged {
// Editorial apply BEFORE the payload write (rescanMultiFileAudioBook's
// ordering): the generic update's format-group pass derives the
// mime from the sentinel (none) and chapters from
// metadata.ChapterCount (unset) — the focused payload write below
// must win. Force rescan applies TOO (faceted plan Phase 13:
// force rescan = original-scan semantics — the full chain
// re-lands on every field, with metadata_overrides as the only
// protection, honored inside the apply).
if manifestChanged || s.forceRescan {
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, sentinel, metadata); applyErr != nil {
fmt.Printf("Warning: %v\n", applyErr)
} else {
// Ledger self-heal (Phase 11): the book's metadata
// re-applied successfully — close its open issues.
s.resolveIssuesForItem(ctx, existing.ID)
}
// Fresh full manifest: the next scan compares against
// TODAY's sidecars, not the stale pre-edit fingerprint.
s.storeMultiFileManifest(ctx, libraryID, rootDir, sentinel, rootDir)
}
if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
return err
}
s.changedItems++
// The book is demonstrably back and fresh: lift any archive
// state the missing-root scans set (the same lift the
// unchanged path applies).
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 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)
}
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
// Seed the sidecar manifest at create (Phase 6): names derive
// from the sentinel — the exact representative the extractor
// reads — so the manifest can never disagree with extraction.
s.seedMultiFileManifest(ctx, libraryID, created.ID, rootRel, sentinel, rootDir, totalSize)
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 {
// Write-after-success discipline (2026-10-21 incident: multi-file
// audiobooks surfaced as 1 second long because a re-extraction wrote
// a NULL/empty audio_metadata over a populated one). An empty payload
// is a FAILED extraction, not a book with no audio: when the row
// already carries audio data, keep it and skip the write — the next
// scan retries, and the reader keeps its manifest in the meantime.
if audioPayloadIsEmpty(payload) && existing.AudioMetadata != nil {
fmt.Printf("[AUDIO] WARNING: re-extraction of '%s' produced an empty audio payload; keeping the existing audio_metadata\n", existing.Title)
return nil
}
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
}
// audioPayloadIsEmpty reports whether a payload carries no audio facts
// at all — no tracks, no chapters, no duration. Such a payload is the
// signature of a failed/absent extraction, never of a real book.
func audioPayloadIsEmpty(payload *AudioMetadataPayload) bool {
return payload == nil ||
(len(payload.Tracks) == 0 && len(payload.Chapters) == 0 && payload.DurationSeconds <= 0)
}
// multifileSidecarChanged runs the manifest ladder for a folder/zip book
// (faceted plan Phase 6): a size-stable multi-file book is changed by
// EDITING its sidecars — the track-count/size fingerprint never saw it,
// not even on force rescan. The bulk-map entry is keyed by the item's
// file_path, but sidecar NAMES derive from the representative the
// extractor reads (the sentinel inside a folder root, the zip itself).
// NULL stored manifest → backfill only (deploy-day rule); no bulk entry
// (nil map, brand-new item) → not detected this pass.
func (s *MediaScanner) multifileSidecarChanged(ctx context.Context, libraryID pgtype.UUID, itemPath, representative, dir string) bool {
entry, ok := s.scanStateLookup(libraryID, itemPath)
if !ok {
return false
}
return s.detectSidecarChangeWithRepresentative(ctx, entry, libraryID, representative, dir)
}
// applyMultiFileSidecarEdit lands the once-extracted book metadata
// (sidecars → folder structure → first-track tags → fallback title) on
// the editorial columns of an EXISTING folder/zip book — exactly what
// rescanMultiFileAudioBook applies; metadata_overrides stay protected
// inside the update. The caller runs the focused payload write AFTER
// this: the generic update's format-group pass derives the mime type
// from the sentinel/zip path (none/audio-container) and the chapter
// count from metadata.ChapterCount (unset on the book chain), so the
// payload write must win. The playlist hook re-runs because a
// "(Reading Order)" entry added to an EXISTING book's sidecar still
// creates its collection (add-if-missing is idempotent).
func (s *MediaScanner) applyMultiFileSidecarEdit(ctx context.Context, existing database.MediaItems, representative string, metadata *MediaMetadata) error {
if err := s.updateMediaItemWithMetadata(ctx, existing, representative, metadata); err != nil {
return fmt.Errorf("failed to apply sidecar metadata to %s: %w", existing.FilePath, err)
}
s.syncPlaylistCollectionsAfterCreate(ctx, existing.ID, metadata.PlaylistSeries)
fmt.Printf("[AUDIO] Sidecar metadata applied to '%s'\n", existing.Title)
return nil
}
// seedMultiFileManifest inserts the new book's scan-state entry and
// hashes its sidecars into the initial manifest — the multifile twin of
// the single-file create tail: the next scan compares instead of
// backfilling, so an edit is detected on its first post-seed scan.
func (s *MediaScanner) seedMultiFileManifest(ctx context.Context, libraryID pgtype.UUID, itemID pgtype.UUID, relPath, representative, dir string, totalSize int64) {
newEntry := &scanEntry{ID: itemID, FilePath: relPath, FileSize: totalSize}
s.scanStateUpsert(libraryID, newEntry)
if computed := statSidecarManifest(representative, dir); len(computed.Entries) > 0 {
s.storeManifest(ctx, libraryID, newEntry, hashSidecarManifest(representative, dir, computed))
} else {
// Sidecar-less book still needs an ev home (faceted plan Phase
// 10): stamp — the book was JUST extracted by the current
// parser, so the stamp is truthful.
s.storeManifest(ctx, libraryID, newEntry, &sidecarManifest{Version: sidecarManifestVersion})
}
}
// storeMultiFileManifest refreshes the book's full manifest after a
// change-path apply: the next scan compares against TODAY's sidecars,
// not the stale pre-edit fingerprint.
func (s *MediaScanner) storeMultiFileManifest(ctx context.Context, libraryID pgtype.UUID, itemPath, representative, dir string) {
if entry, ok := s.scanStateLookup(libraryID, itemPath); ok {
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(representative, dir, statSidecarManifest(representative, dir)))
}
}
// 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)
// Manifest-gated sidecar detection (faceted plan Phase 6) — the zip
// itself is both the map key and the representative: its sidecars are
// the siblings the extractor reads (metadata.json next to the zip,
// "<zip-basename>.<ext>" covers).
manifestChanged := false
if existingErr == nil && !s.forceRescan {
manifestChanged = s.multifileSidecarChanged(ctx, libraryID, zipPath, zipPath, filepath.Dir(zipPath))
}
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(entries)) && !manifestChanged {
// Archive-restore parity with the folder path (Phase 6 fix): a
// zip book whose file returned must not stay archived forever.
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
} else {
fmt.Printf("[AUDIO] Restored zip book '%s' (file is back)\n", existing.Title)
}
}
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 || manifestChanged {
// Editorial apply BEFORE the payload write — see the folder
// processor for the format-group ordering rationale. Force
// rescan applies too (Phase 13: original-scan semantics,
// metadata_overrides honored inside the apply).
if manifestChanged || s.forceRescan {
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, zipPath, metadata); applyErr != nil {
fmt.Printf("Warning: %v\n", applyErr)
} else {
// Ledger self-heal (Phase 11): the book's metadata
// re-applied successfully — close its open issues.
s.resolveIssuesForItem(ctx, existing.ID)
}
s.storeMultiFileManifest(ctx, libraryID, zipPath, zipPath, filepath.Dir(zipPath))
}
if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
return err
}
s.changedItems++
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
}
}
} else if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
// Parity with the folder change branch: content unchanged but
// the row was archived while the zip was gone.
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
} else {
fmt.Printf("[AUDIO] Restored zip book '%s' (file is back)\n", existing.Title)
}
}
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)
}
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
s.seedMultiFileManifest(ctx, libraryID, created.ID, zipRel, zipPath, filepath.Dir(zipPath), totalSize)
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"
}