fix(metadata): m4b text-track fallback; in-app confirm; save-first write
Three defects from real-world use: - The m4b rewrite assumed every stream survives -map 0 -c copy; files carrying text-ish tracks (the movenc 'text' fourcc) fail the ipod muxer with "Tag text incompatible with output codec id" — exactly the error a write attempt hit in the field. The rewrite now attempts full fidelity first, then retries mapping audio + attached pictures only (-dn -sn): chapters live in the container header (-map_chapters 0) so they survive; every attempt still ffprobe-validates before the atomic rename and the original is never touched on total failure. - The browser confirm() is replaced by an in-app dialog (WriteToFileConfirmDialog, Alpine + templ — no native dialogs). - "Write to file" now SAVES the pending editor edits first (persisted through the same PUT the Save button uses), then writes the file from those DB values, and closes the editor + refreshes the page exactly like Save — previously the endpoint wrote the LAST-SAVED DB values, so edits sitting in the modal never reached the file, and the page didn't visibly update. Test coverage per the owner's bar: EVERY single-file container (MP3, FLAC, M4A, M4B) gets its own runtime-generated fixture, gets written, and the FILE's metadata is then verified independently — ffprobe reads the rewritten container and every mapped key is asserted per format, including the subtitle round-trip split (MP3/FLAC yes, M4A/B no) — and the EPUB/CBZ/sidecar assertions now PARSE the written XML (dc titles/ creators, ComicInfo fields, calibre meta) instead of substring matching. A smart-genre unit test also pins the mixed-blob contract: a recognized shelf wins the genre column even when demoted format words ride along in the same blob.
This commit is contained in:
1 parent
96c1228805
commit
fb1645c90c
8 files changed
+396
-152
No files matched your search
+52
-17
@@ -128,6 +128,7 @@ interface MetadataEditorState {
|
||||
rescanning: boolean;
|
||||
resetting: boolean;
|
||||
writingFile: boolean;
|
||||
writeConfirmOpen: boolean;
|
||||
fileWritePath: string;
|
||||
userRating: number;
|
||||
ratingHover: number;
|
||||
@@ -143,7 +144,10 @@ interface MetadataEditorState {
|
||||
saveMetadata(): Promise<void>;
|
||||
rescanBook(): Promise<void>;
|
||||
resetMetadata(): Promise<void>;
|
||||
writeMetadataToFile(): Promise<void>;
|
||||
writeMetadataToFile(): void;
|
||||
writeConfirmMessage(): string;
|
||||
performWriteToFile(): Promise<void>;
|
||||
persistMetadata(): Promise<boolean>;
|
||||
metadataWriteTarget(): string;
|
||||
metadataWriteHint(): string;
|
||||
starFill(i: number): string;
|
||||
@@ -191,6 +195,7 @@ Alpine.data("bookDetail", () => {
|
||||
// pattern as the editor tags above); the capability mirrors
|
||||
// services.MetadataWriteTargetFor.
|
||||
writingFile: false,
|
||||
writeConfirmOpen: false,
|
||||
fileWritePath: (() => {
|
||||
const modal = document.getElementById("metadata-editor-modal");
|
||||
return modal?.getAttribute("data-write-file-path") || "";
|
||||
@@ -810,7 +815,22 @@ Alpine.data("bookDetail", () => {
|
||||
async saveMetadata() {
|
||||
if (this.saving) return;
|
||||
this.saving = true;
|
||||
try {
|
||||
if (await this.persistMetadata()) {
|
||||
hideMetadataEditor();
|
||||
showToast("Metadata saved successfully", "success");
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// persistMetadata PUTs the editor's current fields (with cover, when
|
||||
// one was staged) — no toast, no reload. saveMetadata and the
|
||||
// write-to-file flow share it, so "Write to file" always persists
|
||||
// the user's PENDING edits before touching the file.
|
||||
async persistMetadata(): Promise<boolean> {
|
||||
try {
|
||||
const mediaId = getMediaId();
|
||||
const formData = collectFormData(this.editorTags);
|
||||
@@ -854,14 +874,10 @@ Alpine.data("bookDetail", () => {
|
||||
throw new Error(err.error || "Failed to save metadata");
|
||||
}
|
||||
}
|
||||
|
||||
hideMetadataEditor();
|
||||
showToast("Metadata saved successfully", "success");
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
return true;
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "Failed to save metadata", "error");
|
||||
} finally {
|
||||
this.saving = false;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -915,19 +931,35 @@ Alpine.data("bookDetail", () => {
|
||||
|
||||
async writeMetadataToFile() {
|
||||
if (this.writingFile) return;
|
||||
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;
|
||||
// In-app dialog (not window.confirm): shows the target-specific
|
||||
// copy; the dialog's confirm button calls performWriteToFile().
|
||||
this.writeConfirmOpen = true;
|
||||
},
|
||||
|
||||
writeConfirmMessage(): string {
|
||||
switch (this.metadataWriteTarget()) {
|
||||
case "embedded-opf":
|
||||
return "The metadata inside this EPUB will be rewritten with your current edits. The file's checksum changes, so device syncs re-key this book.";
|
||||
case "audio-tags":
|
||||
return "This audiobook's embedded tags will be rewritten with your current edits. Audio streams are copied untouched, but the file's checksum changes, so device syncs re-key this book.";
|
||||
case "comicinfo":
|
||||
return "A ComicInfo.xml with your current edits will be written inside this comic archive. The page images are not modified.";
|
||||
default:
|
||||
return "A Calibre-style metadata sidecar with your current edits will be written next to this file. The media file itself is not modified.";
|
||||
}
|
||||
},
|
||||
|
||||
async performWriteToFile() {
|
||||
if (this.writingFile) return;
|
||||
this.writeConfirmOpen = false;
|
||||
this.writingFile = true;
|
||||
const mediaId = getMediaId();
|
||||
try {
|
||||
// Save the pending editor edits FIRST: the endpoint writes the
|
||||
// item's DB values, so what the user sees in the modal must be
|
||||
// in the database before the file is rewritten.
|
||||
if (!(await this.persistMetadata())) return;
|
||||
|
||||
const mediaId = getMediaId();
|
||||
const resp = await fetch(`/api/media-items/${mediaId}/write-metadata`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: getAuthHeader() },
|
||||
@@ -956,6 +988,9 @@ Alpine.data("bookDetail", () => {
|
||||
: "Metadata sidecar written and re-extracted";
|
||||
showToast(label, "success");
|
||||
}
|
||||
// Same page refresh as Save: the editor closes and the page
|
||||
// reflects the written + re-extracted state.
|
||||
hideMetadataEditor();
|
||||
setTimeout(() => window.location.reload(), 800);
|
||||
} catch (e) {
|
||||
showToast(e instanceof Error ? e.message : "Failed to write metadata to file", "error");
|
||||
|
||||
Reference in new issue
Block a user