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.
1369 lines
51 KiB
Go
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"
|
|
}
|