package services // Multi-file audiobook ingestion: folder-of-tracks books and zip-of-audio // books. Both produce the SAME payload shape single-file books write into // media_items.audio_metadata — the chapters array stays the format // abstraction — plus a tracks array naming one playable file per track // (see media_scanner_audio.go). The /audio/:trackIndex endpoint resolves // those paths to bytes; the web player maps global seconds ⇄ track/local // offset, so progress/bookmarks keep their existing coordinates. // // Grouping rule (folder books): the book root is the nearest ancestor of // the track file that holds audio files DIRECTLY, merged upward through // disc/part-style directories ("CD1", "Disc 2", "Part 3", "05") whose // parent has no audio of its own. So // // Author/Title/01.mp3, 02.mp3 → book at Author/Title // Author/Title/CD1/*.mp3, CD2/*.mp3 → one book at Author/Title // Author/BookA/*.mp3, Author/BookB/*.mp3 → two books (siblings never merge) // // A root holding fewer than two tracks falls back to the single-file path. import ( "archive/zip" "bufio" "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "bookhoard/internal/database" "bookhoard/internal/utils" ) // discPartPattern matches directory names that group discs/parts of one // book rather than naming a standalone book: "CD1", "disc 2", "Part3", // "pt. 1", "Vol 2", or a bare two-digit ordering number ("01"). var discPartPattern = regexp.MustCompile(`(?i)^(disc|cd|part|pt|vol|volume)\s*\.?\s*\d{0,3}$|^\d{1,2}$`) // naturalLess orders strings with embedded numbers numerically, so // "02 Track" sorts before "10 Track". Digit runs compare by value; other // bytes compare bytewise. func naturalLess(a, b string) bool { i, j := 0, 0 for i < len(a) && j < len(b) { ca, cb := a[i], b[j] da, db := ca >= '0' && ca <= '9', cb >= '0' && cb <= '9' switch { case da && db: si, sj := i, j for i < len(a) && a[i] >= '0' && a[i] <= '9' { i++ } for j < len(b) && b[j] >= '0' && b[j] <= '9' { j++ } na, nb := strings.TrimLeft(a[si:i], "0"), strings.TrimLeft(b[sj:j], "0") if len(na) != len(nb) { return len(na) < len(nb) } if c := strings.Compare(na, nb); c != 0 { return c < 0 } default: if ca != cb { return ca < cb } i++ j++ } } return len(a)-i < len(b)-j } // sortTracksNatural orders full paths by natural comparison. func sortTracksNatural(paths []string) { sort.Slice(paths, func(x, y int) bool { return naturalLess(paths[x], paths[y]) }) } // subtreeAudioTracks collects playable audio tracks under dir // recursively, natural-sorted by path. Zips don't count (containers, // never tracks — see isAudioTrackFile). func subtreeAudioTracks(dir string) []string { var tracks []string _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil // unreadable subtree: skip silently, best effort } if isAudioTrackFile(path) { tracks = append(tracks, path) } return nil }) sortTracksNatural(tracks) return tracks } // directAudioCount counts playable audio files directly inside dir (not // recursive). func directAudioCount(dir string) int { entries, err := os.ReadDir(dir) if err != nil { return 0 } n := 0 for _, e := range entries { if !e.IsDir() && isAudioTrackFile(e.Name()) { n++ } } return n } // bookRootDirForTrackFile resolves the folder-book root DIRECTORY for an // audio file without walking its subtree (cheap enough to call for every // track visit). ok is false when no ancestor holds audio directly. func bookRootDirForTrackFile(path, libraryRoot string) (root string, ok bool) { dir := filepath.Dir(path) // Climb to the nearest ancestor holding audio files directly. candidate := dir for directAudioCount(candidate) == 0 { parent := filepath.Dir(candidate) if parent == candidate || candidate == libraryRoot || !strings.HasPrefix(candidate, libraryRoot) { return "", false } candidate = parent } // Merge upward through disc/part groupings. Stop at the first // directory that doesn't look like a disc marker, or whose parent // holds audio of its own (that would swallow sibling books). for discPartPattern.MatchString(strings.ToLower(filepath.Base(candidate))) { parent := filepath.Dir(candidate) if parent == candidate || candidate == libraryRoot || !strings.HasPrefix(candidate, libraryRoot) { break } if directAudioCount(parent) > 0 { break } candidate = parent } return candidate, true } // bookRootForTrackFile resolves the folder-book root for an audio file. // ok is false when the resolved root holds fewer than two tracks — the // caller falls back to the single-file ingestion path. func bookRootForTrackFile(path, libraryRoot string) (root string, tracks []string, ok bool) { candidate, ok := bookRootDirForTrackFile(path, libraryRoot) if !ok { return "", nil, false } tracks = subtreeAudioTracks(candidate) return candidate, tracks, len(tracks) >= 2 } // maybeProcessAudioBook resolves the owning library + root folder and // hands an audio file to processAudioBookFile. Called from the scan walk // BEFORE the single-file path; handled=true consumes the file. func (s *MediaScanner) maybeProcessAudioBook(ctx context.Context, path string) (bool, error) { if !isAudioFile(path) { return false, nil } // Extension allowlisting already scopes audio to audiobook libraries, // so any scannable audio file belongs to one. rootFolder := "" for _, folder := range s.folders { if strings.HasPrefix(path, folder) { rootFolder = folder break } } if rootFolder == "" { return false, nil } libraryID := s.defaultLibraryID if !libraryID.Valid { lib, err := s.db.GetLibraryByFolder(ctx, rootFolder) if err != nil { return false, fmt.Errorf("failed to find library for audio file %s: %w", path, err) } libraryID = lib.LibraryID } return s.processAudioBookFile(ctx, path, libraryID, rootFolder) } // processAudioBookFile dispatches one audio file: zips become zip books, // other audio resolves its folder-book root (falls back to the caller's // single-file path when the root holds fewer than two tracks). func (s *MediaScanner) processAudioBookFile(ctx context.Context, path string, libraryID pgtype.UUID, rootFolder string) (bool, error) { if strings.EqualFold(filepath.Ext(path), ".zip") { if err := s.processZipAudioBook(ctx, path, libraryID, rootFolder); err != nil { return true, err } return true, nil } root, ok := bookRootDirForTrackFile(path, rootFolder) if !ok { return false, nil // no audio-bearing ancestor: shouldn't happen } if s.audioBookRoots[root] { return true, nil // already ingested this walk (≥2 verified then) } tracks := subtreeAudioTracks(root) if len(tracks) < 2 { return false, nil // lone file: single-file book, existing path } s.audioBookRoots[root] = true if err := s.processAudioFolderBook(ctx, root, tracks, libraryID, rootFolder); err != nil { return true, err } return true, nil } // audioTrackInfo is the probed fact set for one track file. type audioTrackInfo struct { relPath string // library-relative (folder books) or entry name (zip books) title string duration float64 size int64 disc int // tag disc number (TPOS/disk); 0 = absent trackNum int // tag track number (TRCK/trkn); 0 = absent } // parseTagOrdinal reads "N" or "N/M" tag values (disc, track) into N. func parseTagOrdinal(v string) int { v = strings.TrimSpace(v) if idx := strings.Index(v, "/"); idx != -1 { v = v[:idx] } n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil || n < 0 { return 0 } return n } // applyTrackProbe fills one track's probed facts from its ffprobe // report: duration, disc/track ordering keys, and the display title per // the precedence chain (movement_name → title → caller's fallback). func applyTrackProbe(report *ffprobeReport, info *audioTrackInfo) { if report == nil { return } info.duration = float64(report.Format.Duration) info.disc = parseTagOrdinal(report.tag("disc")) info.trackNum = parseTagOrdinal(report.tag("track")) if t := report.tag("movement_name"); t != "" { info.title = t } else if t := report.tag("title"); t != "" { info.title = t } } // orderTracksByTags reorders probed tracks by (disc, track) when EVERY // track carries a track number — tag order then outranks filename // guesses for multi-disc rips whose files are flat `01.mp3` dumps. Any // gap in coverage falls back to the natural-sorted discovery order. func orderTracksByTags(probed []audioTrackInfo) { if len(probed) < 2 { return } for _, tr := range probed { if tr.trackNum <= 0 { return } } sort.SliceStable(probed, func(x, y int) bool { if probed[x].disc != probed[y].disc { return probed[x].disc < probed[y].disc } if probed[x].trackNum != probed[y].trackNum { return probed[x].trackNum < probed[y].trackNum } return naturalLess(probed[x].relPath, probed[y].relPath) }) } // playlistHint is the ordering/title intent of an m3u/m3u8 playlist in a // book root — a curator's explicit track order and names. type playlistHint struct { refs []string // entry refs in playlist order (slash-normalized) titles map[string]string // normalized ref → #EXTINF title } // findPlaylistHint parses THE playlist of a book root (exactly one // .m3u/.m3u8 among its direct children); zero or several → no hint. func findPlaylistHint(rootDir string) *playlistHint { matches, err := filepath.Glob(filepath.Join(rootDir, "*.m3u")) if err != nil { matches = nil } m8, err := filepath.Glob(filepath.Join(rootDir, "*.m3u8")) if err == nil { matches = append(matches, m8...) } if len(matches) != 1 { return nil } f, err := os.Open(matches[0]) if err != nil { return nil } defer func() { _ = f.Close() }() hint := &playlistHint{titles: map[string]string{}} pendingTitle := "" scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } if strings.HasPrefix(line, "#") { if strings.HasPrefix(strings.ToUpper(line), "#EXTINF:") { if idx := strings.Index(line, ","); idx != -1 { pendingTitle = strings.TrimSpace(line[idx+1:]) } } continue } ref := filepath.ToSlash(line) ref = strings.TrimPrefix(ref, "./") hint.refs = append(hint.refs, ref) if pendingTitle != "" { hint.titles[strings.ToLower(ref)] = pendingTitle } pendingTitle = "" } if len(hint.refs) == 0 { return nil } return hint } // reorder returns the absolute track paths in playlist order (refs // matched by relative path first, then by basename), with any tracks // the playlist didn't mention appended in their natural order. func (h *playlistHint) reorder(absTracks []string) []string { byRel := make(map[string]string, len(absTracks)) byBase := make(map[string][]string, len(absTracks)) for _, abs := range absTracks { rel := filepath.ToSlash(abs) byRel[strings.ToLower(rel)] = abs byBase[strings.ToLower(filepath.Base(abs))] = append(byBase[strings.ToLower(filepath.Base(abs))], abs) } ordered := make([]string, 0, len(absTracks)) used := make(map[string]bool, len(absTracks)) for _, ref := range h.refs { abs, ok := byRel[strings.ToLower(ref)] if !ok { if candidates := byBase[strings.ToLower(filepath.Base(ref))]; len(candidates) > 0 { abs = candidates[0] ok = true } } if ok && !used[abs] { ordered = append(ordered, abs) used[abs] = true } } for _, abs := range absTracks { if !used[abs] { ordered = append(ordered, abs) } } return ordered } // titleFor returns the playlist's #EXTINF title for an absolute track // path (matched by relative path, then basename); "" when none. func (h *playlistHint) titleFor(abs string) string { if h == nil { return "" } if t, ok := h.titles[strings.ToLower(filepath.ToSlash(abs))]; ok { return t } return h.titles[strings.ToLower(filepath.Base(abs))] } // cleanTrackTitle derives a display title from a filename when the track // carries no tag: strips extension, leading track numbers and separators. func cleanTrackTitle(path string) string { base := filepath.Base(path) title := strings.TrimSpace(strings.TrimSuffix(base, filepath.Ext(base))) trimmed := strings.TrimLeft(title, "0123456789") if trimmed != title && trimmed != "" { title = strings.TrimLeft(trimmed, " -_.") } if title == "" { title = base } return title } // buildMultiFileAudioPayload assembles the audio_metadata payload for a // multi-file book: layout, cumulative track start/end, chapters (sidecar // chapters win; otherwise the track boundaries ARE the chapters) and the // aggregate duration. func buildMultiFileAudioPayload(layout string, payload *AudioMetadataPayload, tracks []audioTrackInfo) *AudioMetadataPayload { if payload == nil { payload = &AudioMetadataPayload{} } payload.Layout = layout payload.Tracks = nil clock := 0.0 for i, tr := range tracks { end := clock + tr.duration payload.Tracks = append(payload.Tracks, AudioTrack{ Index: i, Path: tr.relPath, Title: tr.title, Start: clock, End: end, DurationSeconds: tr.duration, SizeBytes: tr.size, }) clock = end } payload.DurationSeconds = clock // Curated sidecar chapters describe the whole book in global seconds // and keep priority; otherwise tracks are the chapters (the // documented folder-as-book shape: same {index,title,start,end}). if len(payload.Chapters) == 0 && len(payload.Tracks) > 0 { for _, tr := range payload.Tracks { payload.Chapters = append(payload.Chapters, AudioChapter{ Index: tr.Index, Title: tr.Title, Start: tr.Start, End: tr.End, }) } } return payload } // extractAudioBookMetadata gathers book-level metadata for a multi-file // book: sidecars next to the representative path (a sentinel inside the // book root for folders, the zip itself for archives — the sidecar checks // all look at Dir(representative)), then folder-structure patterns, then // the first track's tags (album/artist/composer/grouping/freeform atoms // via applyAudioTagFallbacks), then fallbackTitle (the book directory // name for folder books, the zip name for zip books — passed explicitly // because Dir(representative) means different things per layout). // Embedded cover art is the cover fallback of last resort: sidecar // covers win. firstTrackAbs is the first track's absolute path for // folder books ("" for zip books — their first entry is temp-extracted // on demand). func (s *MediaScanner) extractAudioBookMetadata(representative, rootFolder string, firstReport *ffprobeReport, firstTrackAbs, fallbackTitle string) *MediaMetadata { extracted, err := s.extractMetadata(representative) var metadata *MediaMetadata if err != nil || extracted == nil { metadata = &MediaMetadata{} } else { metadata = extracted } // The folder-book representative is a sentinel INSIDE the root (the // sidecar checks all look at Dir(representative)). The generic // extractor's filename-title fallback would name the book // ".audiobook-root" and every real source below would see a non-empty // title — strip it so sidecars, folder structure, tags and finally // the directory name get their turn. if metadata.Title != "" && strings.HasPrefix(filepath.Base(representative), ".") && metadata.Title == strings.TrimSuffix(filepath.Base(representative), filepath.Ext(representative)) { metadata.Title = "" } if metadata.AudioMeta == nil { metadata.AudioMeta = readSidecarAudio(representative) } if folderMetadata := s.extractFolderStructureMetadata(representative, rootFolder); folderMetadata != nil { if metadata.Title == "" && folderMetadata.Title != "" { metadata.Title = folderMetadata.Title } if metadata.Author == "" && folderMetadata.Author != "" { metadata.Author = folderMetadata.Author } if metadata.Series == "" && folderMetadata.Series != "" { metadata.Series = folderMetadata.Series } if metadata.SeriesNumber == 0 && folderMetadata.SeriesNumber > 0 { metadata.SeriesNumber = folderMetadata.SeriesNumber } } // Tag fallbacks run after sidecars and folder structure — the shared // helper fills title/author/narrators/series/date/genre from the // first track's tags (album/artist/composer/grouping/date/genre plus // Audible freeform atoms), then embedded cover art as the cover // fallback of last resort. applyAudioTagFallbacks(metadata, firstReport) if metadata.Title == "" && fallbackTitle != "" { metadata.Title = strings.ReplaceAll(fallbackTitle, "_", " ") } if metadata.Author == "" { metadata.Author = "Unknown" } if metadata.CoverPath == "" { if cover := findSidecarCover(representative); cover != "" { metadata.CoverPath = s.getRelativePath(cover) } } if metadata.CoverPath == "" && firstTrackAbs != "" { if cover := extractEmbeddedAudioCover(firstTrackAbs, representative); cover != "" { metadata.CoverPath = s.getRelativePath(cover) } } return metadata } // createAudioBookItem inserts the media_items row shared by folder and // zip books. The hash columns stay NULL — content dedup is path-based for // multi-file books (there is no single byte stream to hash). func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype.UUID, metadata *MediaMetadata, relPath string, totalSize int64, mimeType string, modTime pgtype.Timestamptz) (database.MediaItems, error) { // A2c §2.7: same title/subtitle-derived series fallback as the // single-file path — folder-of-tracks books carry the same naming. applyTitleDerivedSeries(metadata) return 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), }) } // 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 keeps the editorial-keep contract (the // per-book RescanMediaItem is the editorial tool), so it skips // the apply even when the ladder flagged a drift it caused. if manifestChanged && !s.forceRescan { if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, sentinel, metadata); applyErr != nil { fmt.Printf("Warning: %v\n", applyErr) } // 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 { 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 } // 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)) } } // 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, // "." 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 ".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. if manifestChanged && !s.forceRescan { if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, zipPath, metadata); applyErr != nil { fmt.Printf("Warning: %v\n", applyErr) } 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" }