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) + "" + name + ">\n")
+ }
+ }
+ tagInt := func(name string, v int) {
+ if v > 0 {
+ b.WriteString("\t<" + name + ">" + strconv.Itoa(v) + "" + name + ">\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) {