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
@@ -559,11 +559,14 @@ func probeDurationSeconds(path string) float64 {
|
||||
}
|
||||
|
||||
// writeAudioTags rewrites the container's tags via ffmpeg stream copy:
|
||||
// `-c copy` never re-encodes, `-map 0` keeps every stream (cover art
|
||||
// included), `-map_metadata 0` preserves existing global tags, and
|
||||
// `-map_chapters 0` keeps the m4b chapter list. Output is validated
|
||||
// (duration within tolerance of the source) BEFORE the atomic rename —
|
||||
// a lossy container rewrite must never replace a good book.
|
||||
// `-c copy` never re-encodes, `-map_metadata 0` preserves existing
|
||||
// global tags, and `-map_chapters 0` keeps the m4b chapter list
|
||||
// (chapters live in the header, not a stream). Attempt 1 copies every
|
||||
// stream; attempt 2 — for real-world files carrying text/subtitle/data
|
||||
// tracks the ipod muxer rejects ("Tag text incompatible with output
|
||||
// codec id ...") — maps audio + attached pictures only. Every attempt
|
||||
// is ffprobe-VALIDATED (duration within tolerance of the source)
|
||||
// BEFORE the atomic rename; a failure never touches the original.
|
||||
func (w *MetadataWriter) writeAudioTags(absPath string, item *database.MediaItems) (string, error) {
|
||||
info, err := os.Stat(absPath)
|
||||
if err != nil {
|
||||
@@ -577,41 +580,58 @@ func (w *MetadataWriter) writeAudioTags(absPath string, item *database.MediaItem
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(absPath))
|
||||
tmpPath := absPath + ".tmp-write" + ext
|
||||
args := []string{"-hide_banner", "-loglevel", "error", "-y",
|
||||
|
||||
// Shared args; each attempt appends its own stream mapping.
|
||||
base := []string{"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", absPath,
|
||||
"-map", "0", "-map_metadata", "0", "-map_chapters", "0",
|
||||
"-c", "copy",
|
||||
"-map_metadata", "0", "-map_chapters", "0", "-c", "copy",
|
||||
}
|
||||
for _, kv := range audioTagArgs(item, ext) {
|
||||
args = append(args, "-metadata", kv[0]+"="+kv[1])
|
||||
}
|
||||
args = append(args, tmpPath)
|
||||
|
||||
if out, err := exec.Command("ffmpeg", args...).CombinedOutput(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("ffmpeg tag rewrite failed: %s", strings.TrimSpace(string(out)))
|
||||
base = append(base, "-metadata", kv[0]+"="+kv[1])
|
||||
}
|
||||
|
||||
// Validation gate: the rewritten container must still hold the
|
||||
// same audio (duration within 1s or 0.5%, whichever is looser).
|
||||
outDuration := probeDurationSeconds(tmpPath)
|
||||
if outDuration <= 0 {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("rewritten audio failed validation (ffprobe read no duration); original untouched")
|
||||
// Stream maps, best fidelity first.
|
||||
streamMaps := [][]string{
|
||||
{"-map", "0"}, // every stream (covers, chapters-as-streams)
|
||||
// Audio + attached pictures; drop text/subtitle/data tracks —
|
||||
// the ipod muxer refuses mov_text-style streams outright.
|
||||
{"-map", "0:a", "-map", "0:t?", "-dn", "-sn"},
|
||||
}
|
||||
if srcDuration > 0 {
|
||||
tolerance := math.Max(1.0, srcDuration*0.005)
|
||||
if math.Abs(outDuration-srcDuration) > tolerance {
|
||||
|
||||
var lastErr error
|
||||
for i, streamMap := range streamMaps {
|
||||
args := append(append([]string{}, base...), streamMap...)
|
||||
args = append(args, tmpPath)
|
||||
if out, err := exec.Command("ffmpeg", args...).CombinedOutput(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("rewritten audio failed validation (duration %.2fs → %.2fs); original untouched", srcDuration, outDuration)
|
||||
lastErr = fmt.Errorf("ffmpeg tag rewrite failed (attempt %d): %s", i+1, strings.TrimSpace(string(out)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, absPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("failed to replace audio file: %w", err)
|
||||
// Validation gate: the rewritten container must still hold the
|
||||
// same audio (duration within 1s or 0.5%, whichever is looser).
|
||||
outDuration := probeDurationSeconds(tmpPath)
|
||||
if outDuration <= 0 {
|
||||
os.Remove(tmpPath)
|
||||
lastErr = fmt.Errorf("rewritten audio failed validation (ffprobe read no duration)")
|
||||
continue
|
||||
}
|
||||
if srcDuration > 0 {
|
||||
tolerance := math.Max(1.0, srcDuration*0.005)
|
||||
if math.Abs(outDuration-srcDuration) > tolerance {
|
||||
os.Remove(tmpPath)
|
||||
lastErr = fmt.Errorf("rewritten audio failed validation (duration %.2fs → %.2fs)", srcDuration, outDuration)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, absPath); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("failed to replace audio file: %w", err)
|
||||
}
|
||||
return absPath, nil
|
||||
}
|
||||
return absPath, nil
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
// ---- CBZ ComicInfo.xml (5c) ----
|
||||
|
||||
@@ -330,6 +330,22 @@ func TestBuildComicInfoXMLSkipsFractionalNumber(t *testing.T) {
|
||||
assert.NotContains(t, buildComicInfoXML(item), "<Number>")
|
||||
}
|
||||
|
||||
// The demoted-genre contract in full (885d6140): a format word only
|
||||
// sinks to tags-only when it's ALONE. A blob where ANY candidate maps
|
||||
// to a shelf still wins the genre column; the demoted word never does.
|
||||
func TestPickSmartGenreMixedBlob(t *testing.T) {
|
||||
genre, all := pickSmartGenre([]string{"Audiobook, Science Fiction, Unabridged"})
|
||||
assert.Equal(t, "science fiction", genre, "a recognized shelf wins the column")
|
||||
assert.Contains(t, all, "Audiobook", "the demoted word is still a tag candidate")
|
||||
assert.Contains(t, all, "Unabridged")
|
||||
|
||||
// Format words alone → column empty, everything tags-only.
|
||||
genre, all = pickSmartGenre([]string{"Audiobook, Unabridged"})
|
||||
assert.Empty(t, genre, "demoted words alone never fill the genre column")
|
||||
assert.Contains(t, all, "Audiobook")
|
||||
assert.Contains(t, all, "Unabridged")
|
||||
}
|
||||
|
||||
func dateOf(s string) time.Time {
|
||||
tm, _ := time.Parse("2006-01-02", s)
|
||||
return tm
|
||||
|
||||
Reference in new issue
Block a user