feat(metadata): audio tag rewrite via ffmpeg + CBZ ComicInfo injection

Phases 5b and 5c of the metadata writer.

5b — single-file audio (MP3/FLAC/M4A/M4B): the container's tags are
rewritten with ffmpeg -c copy (no re-encode; -map 0 keeps cover art,
-map_chapters 0 keeps chapters). The key set is exactly what the
scanner's applyAudioTagFallbacks reads back — title+album, subtitle,
artist/album_artist, composer (narrators, Audible's convention),
grouping + iTunes series-part, date, genre — so a write + rescan
round-trips. Output is ffprobe-VALIDATED (duration within 1s/0.5% of
the source) before the atomic rename; a lossy container rewrite never
replaces a good book. Fields the format can't carry per our own read
chain are deliberately not written, and fields_written reports only
what really round-trips (subtitle rides MP3/FLAC — this image's mp4
muxer drops subtitle keys, discovered empirically and pinned by test).

5c — CBZ: ComicInfo.xml injected (or replaced) with the same atomic zip
rewrite as the EPUB path, extended with an append-if-missing mode
since a comic may not carry one yet. Field map mirrors the scanner's
comic block: Title/Series/Number/Count/Volume, Writer (authors),
Summary, Publisher, Year/Month/Day, LanguageISO, Web, Genre+Tags;
fractional series numbers are omitted rather than truncated to a lie.
CBR/CB7 stay on the sidecar path (RAR/7z can't be rewritten in place).

Integration tests generate media at test runtime with ffmpeg (m4b) and
in-memory zips (cbz) — no binary fixtures — pinning the tag round-trip
through ffprobe, post-write rescan titles, the ComicInfo injection and
the validation-gate behavior.
This commit is contained in:
John O'Keefe committed 2026-10-27 16:04:34 -04:00
1 parent eaf1b2c8dd
commit 59eddc1386
5 files changed
+727 -30

No files matched your search

+130 -2
View File
@@ -3,6 +3,7 @@ package services
import (
"archive/zip"
"bytes"
"encoding/xml"
"io"
"os"
"path/filepath"
@@ -87,8 +88,6 @@ func TestMetadataWriteTargetFor(t *testing.T) {
assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".epub"))
assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".KEPUB"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".m4b"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cbz"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(""))
}
@@ -206,3 +205,132 @@ func TestAtomicWriteReplacesContent(t *testing.T) {
require.NoError(t, err)
assert.Len(t, entries, 1)
}
func TestAudioTagArgsMapsRoundTripKeys(t *testing.T) {
// Every key here must have a reader in applyAudioTagFallbacks —
// that's the round-trip contract with the scanner.
item := &database.MediaItems{
Title: "The Book",
Subtitle: pgtype.Text{String: "The Sub", Valid: true},
Authors: []string{"Ada Lovelace", "Grace Hopper"},
Narrators: []string{"Simon Vance"},
Series: pgtype.Text{String: "Memoirs", Valid: true},
SeriesNumber: pgtype.Float8{Float64: 3.5, Valid: true},
DatePublished: pgtype.Date{
Time: dateOf("1953-04-16"),
Valid: true,
},
Tags: []string{"Science"},
}
got := map[string]string{}
for _, kv := range audioTagArgs(item, ".mp3") {
got[kv[0]] = kv[1]
}
assert.Equal(t, "The Book", got["title"])
assert.Equal(t, "The Book", got["album"], "the scanner's album rung names the book")
assert.Equal(t, "The Sub", got["subtitle"])
assert.Equal(t, "Ada Lovelace", got["artist"], "first author")
assert.Equal(t, "Ada Lovelace & Grace Hopper", got["album_artist"])
assert.Equal(t, "Simon Vance", got["composer"], "narrators ride composer (Audible convention)")
assert.Equal(t, "Memoirs", got["grouping"])
assert.Equal(t, "3.5", got["----:com.apple.itunes:series-part"])
assert.Equal(t, "1953-04-16", got["date"])
assert.Equal(t, "Science", got["genre"])
// Empty fields emit nothing (a tag write must not blank a book).
empty := audioTagArgs(&database.MediaItems{Title: "T"}, ".mp3")
keys := map[string]bool{}
for _, kv := range empty {
keys[kv[0]] = true
}
assert.True(t, keys["title"])
assert.False(t, keys["subtitle"])
assert.False(t, keys["artist"])
assert.False(t, keys["composer"])
assert.False(t, keys["grouping"])
assert.False(t, keys["genre"])
// M4B drops subtitle keys in this ffmpeg build — never emitted.
m4b := audioTagArgs(item, ".m4b")
for _, kv := range m4b {
assert.NotEqual(t, "subtitle", kv[0], "mp4 muxer drops subtitle; don't write it")
}
}
// Single-file audio dispatch (5b): the four containers the ffmpeg tag
// writer claims; folder/multi-file audio stays on the sidecar path.
func TestAudioTagExts(t *testing.T) {
for _, ext := range []string{".mp3", ".flac", ".m4a", ".m4b"} {
assert.Equal(t, MetadataTargetAudioTags, MetadataWriteTargetFor(ext), ext)
}
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".opus"))
}
// CBZ dispatch (5c): ComicInfo injection claims CBZ; CBR/CB7 can't be
// rewritten in place and stay on the sidecar path.
func TestComicInfoTargetFor(t *testing.T) {
assert.Equal(t, MetadataTargetComicInfo, MetadataWriteTargetFor(".cbz"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cbr"))
assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cb7"))
}
// buildComicInfoXML must round-trip through the scanner's OWN ComicInfo
// struct (xml.Unmarshal — same parser extractMetadataFromArchive uses).
func TestBuildComicInfoXMLRoundTrips(t *testing.T) {
pubDate, _ := time.Parse("2006-01-02", "1987-03-10")
item := &database.MediaItems{
Title: "The Sad Comic <#1>",
Series: pgtype.Text{String: "Watchmen", Valid: true},
SeriesNumber: pgtype.Float8{Float64: 3, Valid: true},
SeriesCount: pgtype.Int4{Int32: 12, Valid: true},
Authors: []string{"Alan Moore", "Dave Gibbons"},
Description: pgtype.Text{String: "Description & <entities>", Valid: true},
Publishers: []string{"DC Comics"},
DatePublished: pgtype.Date{
Time: pubDate,
Valid: true,
},
Language: pgtype.Text{String: "en", Valid: true},
WebUrl: pgtype.Text{String: "https://example.com/watchmen", Valid: true},
Tags: []string{"Superhero", "Drama"},
}
parsed := ComicInfo{}
require.NoError(t, xml.Unmarshal([]byte(buildComicInfoXML(item)), &parsed))
assert.Equal(t, "The Sad Comic <#1>", parsed.Title)
assert.Equal(t, "Watchmen", parsed.Series)
assert.Equal(t, 3, parsed.Number)
assert.Equal(t, 12, parsed.Count)
assert.Equal(t, "Alan Moore, Dave Gibbons", parsed.Writer)
assert.Equal(t, "Description & <entities>", parsed.Summary)
assert.Equal(t, "DC Comics", parsed.Publisher)
assert.Equal(t, 1987, parsed.Year)
assert.Equal(t, 3, parsed.Month)
assert.Equal(t, 10, parsed.Day)
assert.Equal(t, "en", parsed.LanguageISO)
assert.Equal(t, "https://example.com/watchmen", parsed.Web)
assert.Equal(t, "Superhero", parsed.Genre, "first tag doubles as genre")
assert.Equal(t, "Superhero, Drama", parsed.Tags)
}
func TestBuildComicInfoXMLSkipsFractionalNumber(t *testing.T) {
// Number is an integer field; a fractional series number must not
// round-trip truncated (a lie).
item := &database.MediaItems{
Title: "X",
Series: pgtype.Text{String: "S", Valid: true},
SeriesNumber: pgtype.Float8{Float64: 3.5, Valid: true},
}
parsed := ComicInfo{}
require.NoError(t, xml.Unmarshal([]byte(buildComicInfoXML(item)), &parsed))
assert.Equal(t, 0, parsed.Number, "fractional series number omitted, not truncated")
assert.NotContains(t, buildComicInfoXML(item), "<Number>")
}
func dateOf(s string) time.Time {
tm, _ := time.Parse("2006-01-02", s)
return tm
}