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

+28 -14
View File
@@ -888,30 +888,41 @@ Alpine.data("bookDetail", () => {
},
// metadataWriteTarget mirrors services.MetadataWriteTargetFor: EPUB
// embeds its OPF, everything else takes a sidecar until the audio
// (5b) and CBZ (5c) writers land.
// embeds its OPF, single-file audio (mp3/flac/m4a/m4b) rewrites its
// container tags, CBZ gets ComicInfo.xml injected, and everything
// else takes a sidecar.
metadataWriteTarget(): string {
const ext = (this.fileWritePath.split(".").pop() || "").toLowerCase();
if (ext === "epub" || ext === "kepub") return "embedded-opf";
if (["mp3", "flac", "m4a", "m4b"].includes(ext)) return "audio-tags";
if (ext === "cbz") return "comicinfo";
return "sidecar-opf";
},
metadataWriteHint(): string {
if (this.metadataWriteTarget() === "embedded-opf") {
const target = this.metadataWriteTarget();
if (target === "embedded-opf") {
return "Rewrite the metadata inside the EPUB, then rescan";
}
if (target === "audio-tags") {
return "Rewrite the audiobook's embedded tags, then rescan";
}
if (target === "comicinfo") {
return "Write ComicInfo.xml inside the comic archive, then rescan";
}
return "Write a Calibre-style metadata sidecar next to the file, then rescan";
},
async writeMetadataToFile() {
if (this.writingFile) return;
if (
!window.confirm(
this.metadataWriteTarget() === "embedded-opf"
? "Rewrite the metadata inside this EPUB with the current saved values? The original file will be modified (the SHA-256 changes, so device syncs re-key this book)."
: "Write a metadata sidecar next to this file with the current saved values? The media file itself is not modified.",
)
) {
const target = this.metadataWriteTarget();
const confirmMsg =
target === "embedded-opf"
? "Rewrite the metadata inside this EPUB with the current saved values? The original file will be modified (the SHA-256 changes, so device syncs re-key this book)."
: target === "audio-tags"
? "Rewrite this audiobook's embedded tags with the current saved values? The audio streams are copied untouched, but the file's checksum changes, so device syncs re-key this book."
: "Write a metadata sidecar next to this file with the current saved values? The media file itself is not modified.";
if (!window.confirm(confirmMsg)) {
return;
}
this.writingFile = true;
@@ -935,12 +946,15 @@ Alpine.data("bookDetail", () => {
"error",
);
} else {
showToast(
const label =
payload.metadata_target === "embedded-opf"
? "Metadata written into the EPUB and re-extracted"
: "Metadata sidecar written and re-extracted",
"success",
);
: payload.metadata_target === "audio-tags"
? "Audio tags written and re-extracted"
: payload.metadata_target === "comicinfo"
? "ComicInfo.xml written and re-extracted"
: "Metadata sidecar written and re-extracted";
showToast(label, "success");
}
setTimeout(() => window.location.reload(), 800);
} catch (e) {