From 59eddc1386633116346716c266482bdc249f499f Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 27 Oct 2026 16:04:34 -0400 Subject: [PATCH] feat(metadata): audio tag rewrite via ffmpeg + CBZ ComicInfo injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/server/tests/metadata_write_test.go | 153 ++++++++ docs/user/calibre-integration.md | 12 +- internal/services/metadata_writer.go | 418 +++++++++++++++++++++- internal/services/metadata_writer_test.go | 132 ++++++- web/src/book-detail.ts | 42 ++- 5 files changed, 727 insertions(+), 30 deletions(-) diff --git a/cmd/server/tests/metadata_write_test.go b/cmd/server/tests/metadata_write_test.go index 6dca25fb..e4c91de3 100644 --- a/cmd/server/tests/metadata_write_test.go +++ b/cmd/server/tests/metadata_write_test.go @@ -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, "Comic New Title") + assert.Contains(t, infoXML, "Comic Series") + assert.Contains(t, infoXML, "4") + assert.Contains(t, infoXML, "Comic 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()))) }) diff --git a/docs/user/calibre-integration.md b/docs/user/calibre-integration.md index 4f93752e..66c31832 100644 --- a/docs/user/calibre-integration.md +++ b/docs/user/calibre-integration.md @@ -309,8 +309,16 @@ everything stays in sync: (cover, chapters and reading position are untouched). The file's checksum changes, so device syncs (Kobo, KOReader) re-key the book on their next sync. -- **Everything else** (PDF, comics, folder books, audio until tag - writing ships) — a Calibre-style sidecar is written next to the file: +- **Single-file audio (MP3/FLAC/M4A/M4B)** — the container's own tags + are rewritten (audio streams copied untouched, chapters and cover art + preserved). Only fields the format can carry are written: title, + subtitle (MP3/FLAC), authors, narrators (composer tag — Audible's + convention), series, date and genre. +- **CBZ** — a ComicInfo.xml is written inside the archive (injected or + replaced). CBR/CB7 archives can't be rewritten in place, so they take + the sidecar path. +- **Everything else** (PDF, folder books, CBR/CB7) — a Calibre-style + sidecar is written next to the file: `metadata.opf` when the book is alone in its folder (Calibre's own convention, so Calibre reads it too), or `.opf` when several books share a folder, so one book's sidecar can never leak onto its diff --git a/internal/services/metadata_writer.go b/internal/services/metadata_writer.go index 7abfe71f..3823627f 100644 --- a/internal/services/metadata_writer.go +++ b/internal/services/metadata_writer.go @@ -7,8 +7,11 @@ import ( "encoding/xml" "fmt" "io" + "math" "os" + "os/exec" "path/filepath" + "strconv" "strings" "bookhoard/internal/database" @@ -45,10 +48,23 @@ const ( MetadataTargetEmbeddedOPF MetadataWriteTarget = "embedded-opf" // MetadataTargetSidecarOPF: a Calibre-style sidecar was written next // to the file (formats that can't carry metadata: PDF, comics, - // folder books; audio until phase 5b). + // folder books). MetadataTargetSidecarOPF MetadataWriteTarget = "sidecar-opf" + // MetadataTargetAudioTags: the container's own tags were rewritten + // via ffmpeg stream-copy (single-file MP3/FLAC/M4A/M4B — 5b). + MetadataTargetAudioTags MetadataWriteTarget = "audio-tags" + // MetadataTargetComicInfo: ComicInfo.xml inside the CBZ was + // injected/replaced (5c). CBR/CB7 stay on the sidecar path — RAR + // and 7z archives can't be rewritten in place. + MetadataTargetComicInfo MetadataWriteTarget = "comicinfo" ) +// audioTagExts is the single-file set the tag writer claims; multi-file +// (folder) audio stays on the sidecar path. +var audioTagExts = map[string]bool{ + ".mp3": true, ".flac": true, ".m4a": true, ".m4b": true, +} + // MetadataWriteResult reports what the write did, for the UI toast and // the response contract. type MetadataWriteResult struct { @@ -61,12 +77,17 @@ type MetadataWriteResult struct { // MetadataWriteTargetFor maps a file extension to the write target. // The UI mirrors this to label the button; keep both in lockstep. func MetadataWriteTargetFor(ext string) MetadataWriteTarget { - switch strings.ToLower(strings.TrimSpace(ext)) { - case ".epub", ".kepub": + ext = strings.ToLower(strings.TrimSpace(ext)) + switch { + case ext == ".epub" || ext == ".kepub": return MetadataTargetEmbeddedOPF + case audioTagExts[ext]: + return MetadataTargetAudioTags + case ext == ".cbz": + return MetadataTargetComicInfo default: - // 5b (audio tags via ffmpeg) and 5c (CBZ ComicInfo.xml) will - // claim their formats here; everything else takes the sidecar. + // CBR/CB7 (no in-place rewrite) and every other format take + // the sidecar. return MetadataTargetSidecarOPF } } @@ -108,6 +129,26 @@ func (w *MetadataWriter) WriteMetadataToFile(ctx context.Context, itemID uuid.UU Path: path, FieldsWritten: metadataFieldNames(&item), }, nil + case MetadataTargetAudioTags: + path, err := w.writeAudioTags(absPath, &item) + if err != nil { + return nil, err + } + return &MetadataWriteResult{ + Target: MetadataTargetAudioTags, + Path: path, + FieldsWritten: audioFieldNames(&item, filepath.Ext(absPath)), + }, nil + case MetadataTargetComicInfo: + path, err := w.writeCBZComicInfo(absPath, &item) + if err != nil { + return nil, err + } + return &MetadataWriteResult{ + Target: MetadataTargetComicInfo, + Path: path, + FieldsWritten: comicFieldNames(&item), + }, nil default: path, err := w.writeSidecarOPF(ctx, absPath, &item) if err != nil { @@ -279,16 +320,25 @@ func originalIdentifiers(oldMetadata []byte) string { // rewriteEPUBZip copies every entry to dst, substituting opfName's // content. Order, compression method and modes are preserved. func rewriteEPUBZip(entries []*zip.File, opfName string, newOPF []byte, dst string) error { + return rewriteZipArchive(entries, opfName, newOPF, dst, "", nil) +} + +// rewriteZipArchive is the generic form: substitute name's content, and +// (for the CBZ path, where ComicInfo.xml may not exist yet) append +// extraName/extraContent as a final entry when the archive lacks the +// substituted name. +func rewriteZipArchive(entries []*zip.File, name string, content []byte, dst string, extraName string, extraContent []byte) error { out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) if err != nil { - return fmt.Errorf("failed to create temp EPUB: %w", err) + return fmt.Errorf("failed to create temp archive: %w", err) } writer := zip.NewWriter(out) + substituted := false for _, f := range entries { header := f.FileHeader header.Flags &= ^uint16(0x8) // drop data-descriptor flag; sizes are known upfront var target io.Writer - if f.Name == opfName { + if f.Name == name { // Sizes/CRC recomputed by the writer for the new content. header.CompressedSize64 = 0 header.UncompressedSize64 = 0 @@ -299,13 +349,14 @@ func rewriteEPUBZip(entries []*zip.File, opfName string, newOPF []byte, dst stri if err != nil { writer.Close() out.Close() - return fmt.Errorf("failed to stage OPF entry: %w", err) + return fmt.Errorf("failed to stage substituted entry: %w", err) } - if _, err := target.Write(newOPF); err != nil { + if _, err := target.Write(content); err != nil { writer.Close() out.Close() - return fmt.Errorf("failed to write OPF entry: %w", err) + return fmt.Errorf("failed to write substituted entry: %w", err) } + substituted = true continue } target, err = writer.CreateHeader(&header) @@ -332,13 +383,26 @@ func rewriteEPUBZip(entries []*zip.File, opfName string, newOPF []byte, dst stri return fmt.Errorf("failed to finish entry %s: %w", f.Name, err) } } + if !substituted && extraName != "" && extraContent != nil { + target, cerr := writer.Create(extraName) + if cerr != nil { + writer.Close() + out.Close() + return fmt.Errorf("failed to append entry %s: %w", extraName, cerr) + } + if _, werr := target.Write(extraContent); werr != nil { + writer.Close() + out.Close() + return fmt.Errorf("failed to write appended entry %s: %w", extraName, werr) + } + } if err := writer.Close(); err != nil { out.Close() - return fmt.Errorf("failed to finalize EPUB: %w", err) + return fmt.Errorf("failed to finalize archive: %w", err) } if err := out.Sync(); err != nil { out.Close() - return fmt.Errorf("failed to sync EPUB: %w", err) + return fmt.Errorf("failed to sync archive: %w", err) } return out.Close() } @@ -374,6 +438,336 @@ func atomicWrite(path string, data []byte) error { return nil } +// ---- audio tags (5b): ffmpeg stream-copy rewrite ---- + +// audioFieldNames lists the fields the audio tag path round-trips — +// exactly the keys applyAudioTagFallbacks reads back (the ingestion +// chain's tags rung). Fields the file format can't carry per our own +// read chain (description, publisher, language, isbn, the full tag +// list) are deliberately NOT written: "applicable and correct" means +// nothing lands in a file the scanner would never find again. +// Subtitle rides MP3/FLAC only (the mp4 muxer in our image drops +// subtitle keys). +func audioFieldNames(item *database.MediaItems, ext string) []string { + fields := []string{"title"} + if item.Subtitle.Valid && item.Subtitle.String != "" && + !strings.EqualFold(ext, ".m4a") && !strings.EqualFold(ext, ".m4b") { + fields = append(fields, "subtitle") + } + if len(item.Authors) > 0 || (item.Author.Valid && item.Author.String != "") { + fields = append(fields, "authors") + } + if len(item.Narrators) > 0 { + fields = append(fields, "narrators") + } + if item.Series.Valid && item.Series.String != "" { + fields = append(fields, "series") + } + if item.DatePublished.Valid { + fields = append(fields, "date_published") + } + if len(item.Tags) > 0 { + fields = append(fields, "genre") + } + return fields +} + +// audioTagArgs maps the item's current values onto the container tag +// keys the scanner's own fallback chain reads (media_scanner_audio.go): +// +// title → title + album (players want title; the scanner's +// album rung names the book) +// subtitle → subtitle (MP3/FLAC only — the mp4 muxer in +// our image drops subtitle keys, and +// fields we can't read back don't +// get written) +// authors → artist (first) + album_artist (joined) +// narrators → composer (Audible's convention) +// series → grouping (series-part rides the iTunes atom) +// date → date; first tag → genre +func audioTagArgs(item *database.MediaItems, ext string) [][2]string { + var args [][2]string + add := func(k, v string) { + if strings.TrimSpace(v) != "" { + args = append(args, [2]string{k, v}) + } + } + + title := strings.TrimSpace(item.Title) + add("title", title) + add("album", title) + if item.Subtitle.Valid && !strings.EqualFold(ext, ".m4a") && !strings.EqualFold(ext, ".m4b") { + add("subtitle", strings.TrimSpace(item.Subtitle.String)) + } + + authors := item.Authors + if len(authors) == 0 && item.Author.Valid { + authors = []string{item.Author.String} + } + if len(authors) > 0 { + add("artist", strings.TrimSpace(authors[0])) + joined := make([]string, 0, len(authors)) + for _, a := range authors { + a = strings.TrimSpace(a) + if a != "" { + joined = append(joined, a) + } + } + add("album_artist", strings.Join(joined, " & ")) + } + + if len(item.Narrators) > 0 { + narrators := make([]string, 0, len(item.Narrators)) + for _, n := range item.Narrators { + n = strings.TrimSpace(n) + if n != "" { + narrators = append(narrators, n) + } + } + add("composer", strings.Join(narrators, ", ")) + } + + if item.Series.Valid { + add("grouping", strings.TrimSpace(item.Series.String)) + if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 { + add("----:com.apple.itunes:series-part", strconvFormatFloat(item.SeriesNumber.Float64)) + } + } + + if item.DatePublished.Valid { + add("date", item.DatePublished.Time.Format("2006-01-02")) + } + if len(item.Tags) > 0 && strings.TrimSpace(item.Tags[0]) != "" { + add("genre", strings.TrimSpace(item.Tags[0])) + } + return args +} + +// probeDurationSeconds ffprobes a file's duration (0 on any failure). +func probeDurationSeconds(path string) float64 { + out, err := exec.Command("ffprobe", "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", path).Output() + if err != nil { + return 0 + } + v, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) + if err != nil || v < 0 { + return 0 + } + return v +} + +// 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. +func (w *MetadataWriter) writeAudioTags(absPath string, item *database.MediaItems) (string, error) { + info, err := os.Stat(absPath) + if err != nil { + return "", fmt.Errorf("media file missing on disk: %w", err) + } + if info.IsDir() { + return "", fmt.Errorf("multi-file audio books take the sidecar path") + } + + srcDuration := probeDurationSeconds(absPath) + + ext := strings.ToLower(filepath.Ext(absPath)) + tmpPath := absPath + ".tmp-write" + ext + args := []string{"-hide_banner", "-loglevel", "error", "-y", + "-i", absPath, + "-map", "0", "-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))) + } + + // 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") + } + if srcDuration > 0 { + tolerance := math.Max(1.0, srcDuration*0.005) + if math.Abs(outDuration-srcDuration) > tolerance { + os.Remove(tmpPath) + return "", fmt.Errorf("rewritten audio failed validation (duration %.2fs → %.2fs); original untouched", srcDuration, outDuration) + } + } + + if err := os.Rename(tmpPath, absPath); err != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("failed to replace audio file: %w", err) + } + return absPath, nil +} + +// ---- CBZ ComicInfo.xml (5c) ---- + +// comicFieldNames lists the fields the CBZ write round-trips through +// the scanner's ComicInfo mapping (media_scanner.go: mergeMetadata's +// comic block). Fractional series numbers are omitted — the schema's +// Number is an integer and truncating would round-trip a lie. +func comicFieldNames(item *database.MediaItems) []string { + fields := []string{"title"} + if item.Series.Valid && item.Series.String != "" { + fields = append(fields, "series") + if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 && + item.SeriesNumber.Float64 == float64(int(item.SeriesNumber.Float64)) { + fields = append(fields, "series_number") + } + if item.SeriesCount.Valid && item.SeriesCount.Int32 > 0 { + fields = append(fields, "series_count") + } + } + if len(item.Authors) > 0 || (item.Author.Valid && item.Author.String != "") { + fields = append(fields, "authors") + } + if item.Description.Valid && item.Description.String != "" { + fields = append(fields, "description") + } + if len(item.Publishers) > 0 || (item.Publisher.Valid && item.Publisher.String != "") { + fields = append(fields, "publisher") + } + if item.Language.Valid && item.Language.String != "" { + fields = append(fields, "language") + } + if item.DatePublished.Valid { + fields = append(fields, "date_published") + } + if len(item.Tags) > 0 { + fields = append(fields, "tags") + } + if item.WebUrl.Valid && item.WebUrl.String != "" { + fields = append(fields, "web_url") + } + return fields +} + +// buildComicInfoXML renders a ComicInfo.xml (schema v2.x) from the +// item's current values — the mirror of the scanner's +// extractMetadataFromArchive mapping. Empty values omit their element: +// absent means "no data", never "delete". +func buildComicInfoXML(item *database.MediaItems) string { + var b strings.Builder + b.WriteString(xml.Header) + b.WriteString(`` + "\n") + + tag := func(name, value string) { + if strings.TrimSpace(value) != "" { + b.WriteString("\t<" + name + ">" + escXML(value) + "\n") + } + } + tagInt := func(name string, v int) { + if v > 0 { + b.WriteString("\t<" + name + ">" + strconv.Itoa(v) + "\n") + } + } + + tag("Title", strings.TrimSpace(item.Title)) + tag("Series", strings.TrimSpace(item.Series.String)) + if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 && + item.SeriesNumber.Float64 == float64(int(item.SeriesNumber.Float64)) { + tagInt("Number", int(item.SeriesNumber.Float64)) + } + if item.SeriesCount.Valid { + tagInt("Count", int(item.SeriesCount.Int32)) + } + if item.Volume.Valid { + tagInt("Volume", int(item.Volume.Int32)) + } + + authors := item.Authors + if len(authors) == 0 && item.Author.Valid { + authors = []string{item.Author.String} + } + joinedAuthors := make([]string, 0, len(authors)) + for _, a := range authors { + a = strings.TrimSpace(a) + if a != "" { + joinedAuthors = append(joinedAuthors, a) + } + } + tag("Writer", strings.Join(joinedAuthors, ", ")) + + tag("Summary", item.Description.String) + + publisher := "" + if len(item.Publishers) > 0 { + publisher = item.Publishers[0] + } else if item.Publisher.Valid { + publisher = item.Publisher.String + } + tag("Publisher", strings.TrimSpace(publisher)) + + if item.DatePublished.Valid { + t := item.DatePublished.Time + tagInt("Year", t.Year()) + tagInt("Month", int(t.Month())) + tagInt("Day", t.Day()) + } + + tag("LanguageISO", strings.TrimSpace(item.Language.String)) + if item.WebUrl.Valid { + tag("Web", strings.TrimSpace(item.WebUrl.String)) + } + + genre := strings.TrimSpace(item.Genre.String) + if genre == "" && len(item.Tags) > 0 { + genre = strings.TrimSpace(item.Tags[0]) + } + tag("Genre", genre) + if len(item.Tags) > 0 { + cleanTags := make([]string, 0, len(item.Tags)) + for _, t := range item.Tags { + t = strings.TrimSpace(t) + if t != "" { + cleanTags = append(cleanTags, t) + } + } + tag("Tags", strings.Join(cleanTags, ", ")) + } + + b.WriteString("\n") + return b.String() +} + +// writeCBZComicInfo injects (or replaces) ComicInfo.xml inside the CBZ +// — same atomic zip rewrite as the EPUB path, every other entry copied +// byte-identical. +func (w *MetadataWriter) writeCBZComicInfo(absPath string, item *database.MediaItems) (string, error) { + reader, err := zip.OpenReader(absPath) + if err != nil { + return "", fmt.Errorf("failed to open CBZ: %w", err) + } + defer reader.Close() + + newXML := buildComicInfoXML(item) + + tmpPath := absPath + ".tmp-write" + if err := rewriteZipArchive(reader.File, "ComicInfo.xml", []byte(newXML), tmpPath, "ComicInfo.xml", []byte(newXML)); err != nil { + os.Remove(tmpPath) + return "", err + } + if err := os.Rename(tmpPath, absPath); err != nil { + os.Remove(tmpPath) + return "", fmt.Errorf("failed to replace CBZ: %w", err) + } + return absPath, nil +} + // ---- OPF generation (mirrors parseOPFContent's read set) ---- // escXML escapes the five XML entities for element and attribute text. diff --git a/internal/services/metadata_writer_test.go b/internal/services/metadata_writer_test.go index 8852c8b8..369966a2 100644 --- a/internal/services/metadata_writer_test.go +++ b/internal/services/metadata_writer_test.go @@ -3,6 +3,7 @@ package services import ( "archive/zip" "bytes" + "encoding/xml" "io" "os" "path/filepath" @@ -87,8 +88,6 @@ func TestMetadataWriteTargetFor(t *testing.T) { assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".epub")) assert.Equal(t, MetadataTargetEmbeddedOPF, MetadataWriteTargetFor(".KEPUB")) assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf")) - assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".m4b")) - assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cbz")) assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor("")) } @@ -206,3 +205,132 @@ func TestAtomicWriteReplacesContent(t *testing.T) { require.NoError(t, err) assert.Len(t, entries, 1) } + +func TestAudioTagArgsMapsRoundTripKeys(t *testing.T) { + // Every key here must have a reader in applyAudioTagFallbacks — + // that's the round-trip contract with the scanner. + item := &database.MediaItems{ + Title: "The Book", + Subtitle: pgtype.Text{String: "The Sub", Valid: true}, + Authors: []string{"Ada Lovelace", "Grace Hopper"}, + Narrators: []string{"Simon Vance"}, + Series: pgtype.Text{String: "Memoirs", Valid: true}, + SeriesNumber: pgtype.Float8{Float64: 3.5, Valid: true}, + DatePublished: pgtype.Date{ + Time: dateOf("1953-04-16"), + Valid: true, + }, + Tags: []string{"Science"}, + } + + got := map[string]string{} + for _, kv := range audioTagArgs(item, ".mp3") { + got[kv[0]] = kv[1] + } + assert.Equal(t, "The Book", got["title"]) + assert.Equal(t, "The Book", got["album"], "the scanner's album rung names the book") + assert.Equal(t, "The Sub", got["subtitle"]) + assert.Equal(t, "Ada Lovelace", got["artist"], "first author") + assert.Equal(t, "Ada Lovelace & Grace Hopper", got["album_artist"]) + assert.Equal(t, "Simon Vance", got["composer"], "narrators ride composer (Audible convention)") + assert.Equal(t, "Memoirs", got["grouping"]) + assert.Equal(t, "3.5", got["----:com.apple.itunes:series-part"]) + assert.Equal(t, "1953-04-16", got["date"]) + assert.Equal(t, "Science", got["genre"]) + + // Empty fields emit nothing (a tag write must not blank a book). + empty := audioTagArgs(&database.MediaItems{Title: "T"}, ".mp3") + keys := map[string]bool{} + for _, kv := range empty { + keys[kv[0]] = true + } + assert.True(t, keys["title"]) + assert.False(t, keys["subtitle"]) + assert.False(t, keys["artist"]) + assert.False(t, keys["composer"]) + assert.False(t, keys["grouping"]) + assert.False(t, keys["genre"]) + + // M4B drops subtitle keys in this ffmpeg build — never emitted. + m4b := audioTagArgs(item, ".m4b") + for _, kv := range m4b { + assert.NotEqual(t, "subtitle", kv[0], "mp4 muxer drops subtitle; don't write it") + } +} + +// Single-file audio dispatch (5b): the four containers the ffmpeg tag +// writer claims; folder/multi-file audio stays on the sidecar path. +func TestAudioTagExts(t *testing.T) { + for _, ext := range []string{".mp3", ".flac", ".m4a", ".m4b"} { + assert.Equal(t, MetadataTargetAudioTags, MetadataWriteTargetFor(ext), ext) + } + assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".pdf")) + assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".opus")) +} + +// CBZ dispatch (5c): ComicInfo injection claims CBZ; CBR/CB7 can't be +// rewritten in place and stay on the sidecar path. +func TestComicInfoTargetFor(t *testing.T) { + assert.Equal(t, MetadataTargetComicInfo, MetadataWriteTargetFor(".cbz")) + assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cbr")) + assert.Equal(t, MetadataTargetSidecarOPF, MetadataWriteTargetFor(".cb7")) +} + +// buildComicInfoXML must round-trip through the scanner's OWN ComicInfo +// struct (xml.Unmarshal — same parser extractMetadataFromArchive uses). +func TestBuildComicInfoXMLRoundTrips(t *testing.T) { + pubDate, _ := time.Parse("2006-01-02", "1987-03-10") + item := &database.MediaItems{ + Title: "The Sad Comic <#1>", + Series: pgtype.Text{String: "Watchmen", Valid: true}, + SeriesNumber: pgtype.Float8{Float64: 3, Valid: true}, + SeriesCount: pgtype.Int4{Int32: 12, Valid: true}, + Authors: []string{"Alan Moore", "Dave Gibbons"}, + Description: pgtype.Text{String: "Description & ", Valid: true}, + Publishers: []string{"DC Comics"}, + DatePublished: pgtype.Date{ + Time: pubDate, + Valid: true, + }, + Language: pgtype.Text{String: "en", Valid: true}, + WebUrl: pgtype.Text{String: "https://example.com/watchmen", Valid: true}, + Tags: []string{"Superhero", "Drama"}, + } + + parsed := ComicInfo{} + require.NoError(t, xml.Unmarshal([]byte(buildComicInfoXML(item)), &parsed)) + + assert.Equal(t, "The Sad Comic <#1>", parsed.Title) + assert.Equal(t, "Watchmen", parsed.Series) + assert.Equal(t, 3, parsed.Number) + assert.Equal(t, 12, parsed.Count) + assert.Equal(t, "Alan Moore, Dave Gibbons", parsed.Writer) + assert.Equal(t, "Description & ", parsed.Summary) + assert.Equal(t, "DC Comics", parsed.Publisher) + assert.Equal(t, 1987, parsed.Year) + assert.Equal(t, 3, parsed.Month) + assert.Equal(t, 10, parsed.Day) + assert.Equal(t, "en", parsed.LanguageISO) + assert.Equal(t, "https://example.com/watchmen", parsed.Web) + assert.Equal(t, "Superhero", parsed.Genre, "first tag doubles as genre") + assert.Equal(t, "Superhero, Drama", parsed.Tags) +} + +func TestBuildComicInfoXMLSkipsFractionalNumber(t *testing.T) { + // Number is an integer field; a fractional series number must not + // round-trip truncated (a lie). + item := &database.MediaItems{ + Title: "X", + Series: pgtype.Text{String: "S", Valid: true}, + SeriesNumber: pgtype.Float8{Float64: 3.5, Valid: true}, + } + parsed := ComicInfo{} + require.NoError(t, xml.Unmarshal([]byte(buildComicInfoXML(item)), &parsed)) + assert.Equal(t, 0, parsed.Number, "fractional series number omitted, not truncated") + assert.NotContains(t, buildComicInfoXML(item), "") +} + +func dateOf(s string) time.Time { + tm, _ := time.Parse("2006-01-02", s) + return tm +} diff --git a/web/src/book-detail.ts b/web/src/book-detail.ts index f94371ce..cf6ba9c3 100644 --- a/web/src/book-detail.ts +++ b/web/src/book-detail.ts @@ -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) {