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

+153
View File
@@ -9,7 +9,9 @@ import (
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
@@ -219,6 +221,157 @@ func TestMetadataWriteToFile(t *testing.T) {
_ = itemB
})
t.Run("m4b tags are rewritten via ffmpeg and survive rescan", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/audio/book.m4b", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
// Generate the fixture at test runtime (AGENTS: no binary media
// in git): 2s of silence through the native AAC encoder, with
// an original title tag to prove the rewrite replaces it.
gen := exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=mono",
"-t", "2", "-c:a", "aac",
"-metadata", "title=Generated Fixture",
"-metadata", "album=Generated Fixture",
abs)
out, err := gen.CombinedOutput()
require.NoError(t, err, "ffmpeg fixture generation failed: %s", string(out))
var itemID string
err = setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Generated Fixture', $2, 'audiobook', 'audio/mp4', 100, NOW(), NOW(), NOW()) RETURNING id`,
libID, rel,
).Scan(&itemID)
require.NoError(t, err)
// Follow the real editor flow: PUT first (records
// metadata_overrides, so the inline rescan keeps the edited
// values instead of folder-structure derivations).
editItem(itemID, map[string]interface{}{
"title": "Audio New Title",
"subtitle": "Audio Sub",
"authors": []string{"Audio Author"},
"narrators": []string{"Narrator Person"},
"series": "Audio Series",
"series_number": 1.5,
"date_published": "1953-04-16",
"tags": []string{"Science"},
})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "audio-tags", body["metadata_target"])
// The container now carries the round-trip keys (probe with the
// same tool the scanner uses).
probe := func() map[string]string {
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
"format_tags", "-of", "json", abs).Output()
require.NoError(t, err)
var parsed struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
}
require.NoError(t, json.Unmarshal(out, &parsed))
return parsed.Format.Tags
}
tags := probe()
assert.Equal(t, "Audio New Title", tags["album"], "the scanner's album rung")
assert.Equal(t, "Audio New Title", tags["title"])
// This ffmpeg's mp4 muxer drops subtitle keys — nothing that
// can't be read back may be written (fields_written omits it).
assert.Empty(t, tags["subtitle"], "subtitle must not be written where it can't round-trip")
assert.Equal(t, "Audio Author", tags["artist"])
assert.Equal(t, "Narrator Person", tags["composer"])
assert.Equal(t, "Audio Series", tags["grouping"])
assert.Equal(t, "Science", tags["genre"])
assert.NotContains(t, tags["album"], "Generated Fixture", "old tag replaced")
writeMap := body["write"].(map[string]interface{})
assert.NotContains(t, writeMap["fields_written"], "subtitle", "honest fields_written")
// Still a valid, same-length audio file after the rewrite.
dur := func() float64 {
out, err := exec.Command("ffprobe", "-v", "error", "-show_entries",
"format=duration", "-of", "default=noprint_wrappers=1:nokey=1", abs).Output()
require.NoError(t, err)
v, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
require.NoError(t, err)
return v
}
assert.Greater(t, dur(), 1.5, "audio survived the rewrite")
// The inline rescan re-read the tags: DB title stays the written one.
var title string
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title))
assert.Equal(t, "Audio New Title", title)
})
t.Run("CBZ gets ComicInfo.xml injected and rescan keeps it", func(t *testing.T) {
rel := fmt.Sprintf("test-metadata-write-%d/comic/book.cbz", os.Getpid())
abs := filepath.Join(uploadsDir, rel)
require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o755))
// Minimal archive: one "page" file (a text stub — cover
// extraction failing is tolerated by the scanner) and no
// ComicInfo.xml yet.
var buf bytes.Buffer
w := zip.NewWriter(&buf)
page, _ := w.Create("page_0001.jpg")
_, _ = page.Write([]byte("not-a-real-jpeg"))
require.NoError(t, w.Close())
require.NoError(t, os.WriteFile(abs, buf.Bytes(), 0o644))
var itemID string
err := setup.DBPool.QueryRow(ctx,
`INSERT INTO media_items (library_id, title, file_path, format_group, mime_type, file_size, created_at, imported_at, updated_at)
VALUES ($1, 'Generated Comic', $2, 'comic_archive', 'application/zip', 100, NOW(), NOW(), NOW()) RETURNING id`,
libID, rel,
).Scan(&itemID)
require.NoError(t, err)
editItem(itemID, map[string]interface{}{
"title": "Comic New Title",
"series": "Comic Series",
"series_number": 4,
"authors": []string{"Comic Writer"},
"tags": []string{"Superhero"},
})
code, body := callWrite(setup.Token, itemID)
require.Equal(t, http.StatusOK, code)
assert.Equal(t, "comicinfo", body["metadata_target"])
reader, err := zip.OpenReader(abs)
require.NoError(t, err)
var infoXML string
for _, f := range reader.File {
if f.Name == "ComicInfo.xml" {
rc, err := f.Open()
require.NoError(t, err)
data, err := io.ReadAll(rc)
require.NoError(t, rc.Close())
require.NoError(t, err)
infoXML = string(data)
}
}
reader.Close()
assert.Contains(t, infoXML, "<Title>Comic New Title</Title>")
assert.Contains(t, infoXML, "<Series>Comic Series</Series>")
assert.Contains(t, infoXML, "<Number>4</Number>")
assert.Contains(t, infoXML, "<Writer>Comic Writer</Writer>")
// The inline rescan reads the ComicInfo back: the DB title and
// series stay the written values.
var title, series string
require.NoError(t, setup.DBPool.QueryRow(ctx,
`SELECT title, series FROM media_items WHERE id = $1`, itemID).Scan(&title, &series))
assert.Equal(t, "Comic New Title", title)
assert.Equal(t, "Comic Series", series)
})
t.Cleanup(func() {
_ = os.RemoveAll(filepath.Join(uploadsDir, fmt.Sprintf("test-metadata-write-%d", os.Getpid())))
})