* fix(scanner): stop reporting an unusable ffprobe as an empty folder parseAudiobookFolder and parsePodcastShow signalled "this folder holds no audio files" by wrapping os.ErrNotExist, and their reconcile callers skipped on that. exec also wraps fs.ErrNotExist when the configured ffprobe binary cannot be run, so a wrong playback.ffmpeg_path made every candidate folder look empty: the scan logged processed=N failed=0, indexed nothing, and gave the operator no clue why the library stayed empty. Introduce an errFolderHasNoMedia sentinel that deliberately does not wrap os.ErrNotExist, and skip on that instead. A folder that disappears between the scan walk and the parse still maps to the sentinel, so a mid-scan rename or delete stays a quiet skip rather than a scan failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(scanner): bound the all-failed scan summary instead of joining every failure The sentinel change in this PR makes a previously-unreachable path reachable. Before it, a misconfigured ffprobe made every candidate folder look empty, so the scan skipped everything and `failed` stayed 0 — the `failedCount == processedCount` branch never fired. Now that an unusable ffprobe propagates as a real failure, that branch is the expected outcome of a first scan with a bad `playback.ffmpeg_path`, and it joins one wrapped error per failed folder. On the 240k-folder library the scan code is written for, `errors.Join` over that slice produces a ~64 MB error string (measured) that is written verbatim into `scan_runs.error_message` and republished over the Redis events channel and the admin SSE stream. The `failures` slice itself also grew unbounded for the whole scan even when the all-failed guard could not fire (any rescan with `skipped > 0`), holding hundreds of megabytes across a multi-hour scan before discarding it. Add a `scanFailures` collector that retains the first 20 failures and counts the rest, joining them with a trailing "and N more failures (elided)". The retained sample still names the cause, which is the entire purpose of the summary. The same 64 MB case now produces 5.4 KB. The ebook and manga scans have the identical shape and the same exposure via their own probe failures, so all four call sites share the collector rather than fixing the two audio paths alone. `failMu` now guards only `cancelErr` and is renamed `cancelMu` to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
328 lines
11 KiB
Go
328 lines
11 KiB
Go
package scanner
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// narratorSuffixRE matches a trailing "read by X" / "(Read by X)" /
|
|
// "(UK Version: Read by X)" / "- read by X" pattern that some audiobook
|
|
// taggers put in the title field. The narrator is already captured as
|
|
// item_people kind=8 from the dedicated narrator tag, so removing this
|
|
// noise yields a clean human-readable title without losing any data.
|
|
//
|
|
// The narrator body deliberately excludes the dash so titles like
|
|
// "Series Name Read by Foo N - Book Title" don't get mistakenly
|
|
// truncated (the "Read by Foo" there is part of the series name, not a
|
|
// narrator credit). Real narrator suffixes never contain a `-` after the
|
|
// "read by".
|
|
var narratorSuffixRE = regexp.MustCompile(`(?i)\s*\(?\s*[-:,]?\s*(UK Version:?|US Version:?)?\s*read by [A-Za-z0-9., '&]+\)?\s*$`)
|
|
|
|
// unabridgedTokenRE matches a parenthesized "(unabridged)" anywhere in
|
|
// the title (sometimes mid-string between series and book). Stripped
|
|
// because it's a format marker, not part of the work's name.
|
|
var unabridgedTokenRE = regexp.MustCompile(`(?i)\s*\(unabridged\)\s*`)
|
|
|
|
// collapseSpacesRE squashes any runs of whitespace into a single space.
|
|
// Used after the strip passes since removing a mid-string token can
|
|
// leave double spaces behind.
|
|
var collapseSpacesRE = regexp.MustCompile(`\s+`)
|
|
var audiobookDedupeTitleTokenRE = regexp.MustCompile(`[^A-Za-z0-9]+`)
|
|
|
|
var audiobookFilesystemTitleNumericIDRE = regexp.MustCompile(`\s*[\[(]\d+[\])]\s*$`)
|
|
var audiobookFilesystemTitleNoiseSuffixRE = regexp.MustCompile(`(?i)(?:\s*[-_. ]+\s*)?(?:nmr|audio\s*book|audiobook|unabridged|abridged)\s*$`)
|
|
|
|
// stripNarratorSuffix removes the narrator-suffix noise and "(unabridged)"
|
|
// markers from a title. Returns the input unchanged when no match.
|
|
// Kept in sync with the SQL `regexp_replace` used by migration 146 so
|
|
// the scanner write path and one-shot backfill produce identical output.
|
|
func stripNarratorSuffix(title string) string {
|
|
cleaned := narratorSuffixRE.ReplaceAllString(title, "")
|
|
cleaned = unabridgedTokenRE.ReplaceAllString(cleaned, " ")
|
|
cleaned = collapseSpacesRE.ReplaceAllString(cleaned, " ")
|
|
return strings.TrimSpace(cleaned)
|
|
}
|
|
|
|
func normalizeAudiobookDedupeTitle(title string) string {
|
|
title = stripNarratorSuffix(title)
|
|
title = audiobookDedupeTitleTokenRE.ReplaceAllString(title, " ")
|
|
title = collapseSpacesRE.ReplaceAllString(title, " ")
|
|
return strings.ToLower(strings.TrimSpace(title))
|
|
}
|
|
|
|
func audiobookDedupeTitlesMatch(a, b string) bool {
|
|
left := normalizeAudiobookDedupeTitle(a)
|
|
right := normalizeAudiobookDedupeTitle(b)
|
|
if left == "" || right == "" {
|
|
return false
|
|
}
|
|
if left == right {
|
|
return true
|
|
}
|
|
if len(left) < len(right) {
|
|
return strings.HasPrefix(right, left+" ")
|
|
}
|
|
return strings.HasPrefix(left, right+" ")
|
|
}
|
|
|
|
// parsedAudiobook is the structured output of parseAudiobookFolder.
|
|
// The scanner write path (Task 8) converts this into media_items +
|
|
// media_files + item_people rows.
|
|
type parsedAudiobook struct {
|
|
Title string
|
|
Author string
|
|
Narrator string
|
|
Series string
|
|
SeriesPosition string
|
|
Year int
|
|
ASIN string
|
|
Overview string
|
|
Genres []string
|
|
Publisher string
|
|
ReleaseDate string
|
|
Language string
|
|
Files []parsedAudiobookFile
|
|
}
|
|
|
|
// parsedAudiobookFile is one audio file belonging to a parsed audiobook.
|
|
// For single-file .m4b audiobooks there is exactly one entry; for
|
|
// multi-file folders (Task 7) there is one per file.
|
|
type parsedAudiobookFile struct {
|
|
Path string
|
|
Chapters []ChapterInfo
|
|
Duration int // seconds
|
|
Bitrate int // kbps
|
|
CodecAudio string // aac, mp3, opus, flac
|
|
Container string // m4b, mp3, mka, ...
|
|
AudioChannels int
|
|
}
|
|
|
|
// parseAudiobookFolder reads a single audiobook folder and returns its
|
|
// structured representation. Recognized layouts:
|
|
// - one audio file in the folder, optionally with embedded chapters
|
|
// - multiple audio files in the folder; each becomes its own
|
|
// parsedAudiobookFile with a single synthesized chapter (title =
|
|
// filename stem); metadata comes from the first file's tags
|
|
//
|
|
// Returns an error wrapping errFolderHasNoMedia when the folder contains zero
|
|
// audio files, so the caller can skip it. Every other error (including an
|
|
// ffprobe binary that cannot be executed) is a real failure and must be
|
|
// reported by the caller.
|
|
func parseAudiobookFolder(ctx context.Context, ffprobePath string, folderPath string) (*parsedAudiobook, error) {
|
|
entries, err := os.ReadDir(folderPath)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
// The folder was renamed or deleted between the scan walk and
|
|
// this read. It holds no media now, so the caller skips it as it
|
|
// would an empty folder rather than counting a scan failure.
|
|
return nil, fmt.Errorf("audiobook folder %s: %w", folderPath, errFolderHasNoMedia)
|
|
}
|
|
return nil, fmt.Errorf("read audiobook folder %s: %w", folderPath, err)
|
|
}
|
|
|
|
var audioFiles []string
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
if SupportsAudioFile(entry.Name()) {
|
|
audioFiles = append(audioFiles, filepath.Join(folderPath, entry.Name()))
|
|
}
|
|
}
|
|
if len(audioFiles) == 0 {
|
|
return nil, fmt.Errorf("audiobook folder %s: %w", folderPath, errFolderHasNoMedia)
|
|
}
|
|
sort.Strings(audioFiles)
|
|
|
|
book := &parsedAudiobook{}
|
|
|
|
if len(audioFiles) == 1 {
|
|
probed, err := ProbeFile(ctx, ffprobePath, audioFiles[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("probe audiobook file %s: %w", audioFiles[0], err)
|
|
}
|
|
book.populateFromTags(probed.FormatTags)
|
|
book.applyFilesystemFallbacks(folderPath, audioFiles)
|
|
book.Files = []parsedAudiobookFile{{
|
|
Path: audioFiles[0],
|
|
Chapters: probed.Chapters,
|
|
Duration: probed.Duration,
|
|
Bitrate: probed.Bitrate,
|
|
CodecAudio: probed.CodecAudio,
|
|
Container: probed.Container,
|
|
AudioChannels: probed.AudioChannels,
|
|
}}
|
|
return book, nil
|
|
}
|
|
|
|
// Multi-file case: read header from the first file, synthesize one
|
|
// chapter per file with title = filename stem. A 0..0 range means the
|
|
// chapter duration is unknown; consumers should treat it as non-seekable.
|
|
probedFirst, err := ProbeFile(ctx, ffprobePath, audioFiles[0])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("probe first audiobook file %s: %w", audioFiles[0], err)
|
|
}
|
|
book.populateFromTags(probedFirst.FormatTags)
|
|
book.applyFilesystemFallbacks(folderPath, audioFiles)
|
|
|
|
book.Files = make([]parsedAudiobookFile, 0, len(audioFiles))
|
|
for i, path := range audioFiles {
|
|
stem := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
|
probed, err := ProbeFile(ctx, ffprobePath, path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("probe audiobook file %s: %w", path, err)
|
|
}
|
|
book.Files = append(book.Files, parsedAudiobookFile{
|
|
Path: path,
|
|
Chapters: []ChapterInfo{{
|
|
Index: i,
|
|
Title: stem,
|
|
StartSeconds: 0,
|
|
EndSeconds: float64(probed.Duration),
|
|
}},
|
|
Duration: probed.Duration,
|
|
Bitrate: probed.Bitrate,
|
|
CodecAudio: probed.CodecAudio,
|
|
Container: probed.Container,
|
|
AudioChannels: probed.AudioChannels,
|
|
})
|
|
}
|
|
return book, nil
|
|
}
|
|
|
|
// populateFromTags fills the audiobook's header fields (Title, Author,
|
|
// Narrator, Series, Year) from the ffprobe format tags. Tags are
|
|
// lower-cased by normalizeFormatTags upstream.
|
|
func (b *parsedAudiobook) populateFromTags(tags map[string]string) {
|
|
b.Title = firstNonEmpty(tags["title"], tags["album"])
|
|
b.Author = firstNonEmpty(tags["album_artist"], tags["artist"], tags["composer"])
|
|
b.Narrator = firstNonEmpty(tags["narrator"], tags["performer"], tags["composer"])
|
|
// Note: do NOT fall back to tags["album"] for Series. In audiobook
|
|
// tagging, `album` is the book title, not the series name — using it
|
|
// as the fallback writes series_name = title for every book without
|
|
// an explicit series tag, producing one fake singleton "series" per
|
|
// title (~141k of them on this server before this fix).
|
|
b.Series = firstNonEmpty(tags["series"], tags["mvnm"])
|
|
b.SeriesPosition = firstNonEmpty(tags["series-part"], tags["mvin"], tags["movement"])
|
|
b.ASIN = firstNonEmpty(tags["asin"], tags["audible_asin"], tags["com.audible.asin"])
|
|
b.Overview = firstNonEmpty(tags["description"], tags["summary"], tags["comment"], tags["©cmt"])
|
|
b.Publisher = firstNonEmpty(tags["publisher"], tags["label"], tags["©pub"])
|
|
b.ReleaseDate = firstNonEmpty(tags["releasedate"], tags["release_date"], tags["date"], tags["year"], tags["releasetime"])
|
|
b.Language = firstNonEmpty(tags["language"], tags["lang"])
|
|
if year := firstNonEmpty(tags["date"], tags["year"], tags["releasetime"]); year != "" {
|
|
if y := parseTagYear(year); y > 0 {
|
|
b.Year = y
|
|
}
|
|
}
|
|
b.Genres = parseGenresFromTags(tags)
|
|
}
|
|
|
|
// parseGenresFromTags pulls genres from the `genre` tag (which is often
|
|
// slash-separated like "Mystery, Thriller & Suspense/Suspense/Fiction")
|
|
// plus tmp_Genre1..tmp_Genre5 sub-tags written by some audiobook taggers.
|
|
// Returns a deduplicated list in input order.
|
|
func parseGenresFromTags(tags map[string]string) []string {
|
|
seen := make(map[string]struct{})
|
|
var out []string
|
|
add := func(g string) {
|
|
g = strings.TrimSpace(g)
|
|
if g == "" {
|
|
return
|
|
}
|
|
key := strings.ToLower(g)
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
out = append(out, g)
|
|
}
|
|
if raw := tags["genre"]; raw != "" {
|
|
for _, part := range strings.Split(raw, "/") {
|
|
for _, sub := range strings.Split(part, ",") {
|
|
add(sub)
|
|
}
|
|
}
|
|
}
|
|
for i := 1; i <= 5; i++ {
|
|
key := fmt.Sprintf("tmp_genre%d", i)
|
|
if v := tags[key]; v != "" {
|
|
add(v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (b *parsedAudiobook) applyFilesystemFallbacks(folderPath string, audioFiles []string) {
|
|
if b == nil || strings.TrimSpace(b.Title) != "" {
|
|
return
|
|
}
|
|
b.Title = deriveAudiobookTitleFromFilesystem(folderPath, audioFiles)
|
|
}
|
|
|
|
func deriveAudiobookTitleFromFilesystem(folderPath string, audioFiles []string) string {
|
|
candidates := []string{filepath.Base(folderPath)}
|
|
if len(audioFiles) == 1 {
|
|
file := audioFiles[0]
|
|
candidates = append(candidates, strings.TrimSuffix(filepath.Base(file), filepath.Ext(file)))
|
|
}
|
|
for _, candidate := range candidates {
|
|
if cleaned := cleanAudiobookFilesystemTitle(candidate); cleaned != "" {
|
|
return cleaned
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func cleanAudiobookFilesystemTitle(title string) string {
|
|
cleaned := strings.TrimSpace(title)
|
|
cleaned = audiobookFilesystemTitleNumericIDRE.ReplaceAllString(cleaned, "")
|
|
cleaned = strings.NewReplacer(".", " ", "_", " ").Replace(cleaned)
|
|
for {
|
|
next := audiobookFilesystemTitleNoiseSuffixRE.ReplaceAllString(cleaned, "")
|
|
if next == cleaned {
|
|
break
|
|
}
|
|
cleaned = next
|
|
}
|
|
cleaned = collapseSpacesRE.ReplaceAllString(cleaned, " ")
|
|
return strings.Trim(cleaned, " -_.")
|
|
}
|
|
|
|
// parseTagYear extracts a 4-digit year (e.g. 1900-9999) from a tag value
|
|
// that may be a bare year ("2024"), an ISO date ("2024-05-23"), or a
|
|
// padded form ("(2024)"). Returns 0 if no plausible year is found.
|
|
func parseTagYear(s string) int {
|
|
s = strings.TrimSpace(s)
|
|
for i := 0; i+4 <= len(s); i++ {
|
|
candidate := s[i : i+4]
|
|
if isAllDigits(candidate) {
|
|
year := 0
|
|
for _, c := range candidate {
|
|
year = year*10 + int(c-'0')
|
|
}
|
|
if year >= 1900 && year <= 9999 {
|
|
return year
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func isAllDigits(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|