feat(audiobooks): parser for single-file audiobook folders

parseAudiobookFolder reads tags + chapters via the existing ProbeFile
(now that Task 5 exposes FormatTags on ProbeData) and produces a
parsedAudiobook struct. Title falls back from "title" tag to "album";
author from "artist" -> "album_artist" -> "composer"; series from
"album" -> "series" -> "mvnm" (Movement Name, used by some MP4 tools).
Year parsed from "date" or "year" tags, tolerating ISO dates and
parenthesized forms.

Single-file case only; multi-file folders (one audio file per chapter)
return a placeholder error and arrive in Task 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-24 14:07:09 +02:00
co-authored by Claude Opus 4.7
parent 5e5e6c18b7
commit 51dc8ef53a
2 changed files with 178 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
package scanner
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// 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
Year int
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
}
// parseAudiobookFolder reads a single audiobook folder and returns its
// structured representation. Recognized layouts:
// - one .m4b (or other audio file) in the folder, optionally with
// embedded chapters
// - multiple audio files in the folder (Task 7 — currently returns an
// error for this case)
//
// Returns an error wrapping os.ErrNotExist when the folder contains zero
// audio files, so the caller can skip it.
func parseAudiobookFolder(ctx context.Context, ffprobePath string, folderPath string) (*parsedAudiobook, error) {
entries, err := os.ReadDir(folderPath)
if err != nil {
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, os.ErrNotExist)
}
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.Files = []parsedAudiobookFile{{
Path: audioFiles[0],
Chapters: probed.Chapters,
}}
return book, nil
}
// Multi-file case is Task 7.
return nil, errors.New("multi-file audiobook folders not yet supported")
}
// 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 = pickFirstNonEmpty(tags["title"], tags["album"])
b.Author = pickFirstNonEmpty(tags["artist"], tags["album_artist"], tags["composer"])
b.Narrator = pickFirstNonEmpty(tags["narrator"], tags["performer"])
b.Series = pickFirstNonEmpty(tags["album"], tags["series"], tags["mvnm"])
if year := pickFirstNonEmpty(tags["date"], tags["year"]); year != "" {
if y := parseTagYear(year); y > 0 {
b.Year = y
}
}
}
// pickFirstNonEmpty returns the first non-empty trimmed value from the
// supplied candidates.
func pickFirstNonEmpty(values ...string) string {
for _, v := range values {
if trimmed := strings.TrimSpace(v); trimmed != "" {
return trimmed
}
}
return ""
}
// 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
}
+41
View File
@@ -0,0 +1,41 @@
package scanner
import (
"context"
"os/exec"
"testing"
)
func TestParseAudiobookFolderSingleM4B(t *testing.T) {
ffprobePath := FFprobePathFromFFmpeg("ffmpeg")
if _, err := exec.LookPath(ffprobePath); err != nil {
ffprobePath = "ffprobe"
if _, err := exec.LookPath(ffprobePath); err != nil {
t.Skip("ffprobe not available")
}
}
ctx := context.Background()
got, err := parseAudiobookFolder(ctx, ffprobePath, "testdata/audiobook_fixtures/single_book")
if err != nil {
t.Fatalf("parseAudiobookFolder: %v", err)
}
if got.Title != "Test Audiobook" {
t.Errorf("Title = %q, want %q", got.Title, "Test Audiobook")
}
if got.Author != "Test Author" {
t.Errorf("Author = %q, want %q", got.Author, "Test Author")
}
if got.Series != "Test Series" {
t.Errorf("Series = %q, want %q", got.Series, "Test Series")
}
if got.Year != 2024 {
t.Errorf("Year = %d, want 2024", got.Year)
}
if len(got.Files) != 1 {
t.Fatalf("got %d files, want 1", len(got.Files))
}
if len(got.Files[0].Chapters) != 2 {
t.Errorf("file 0 chapters = %d, want 2", len(got.Files[0].Chapters))
}
}