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:
1 parent
eaf1b2c8dd
commit
59eddc1386
5 files changed
+727
-30
No files matched your search
+28
-14
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user