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
@@ -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(`<ComicInfo xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">` + "\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("</ComicInfo>\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.
|
||||
|
||||
@@ -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 & <entities>", 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 & <entities>", 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), "<Number>")
|
||||
}
|
||||
|
||||
func dateOf(s string) time.Time {
|
||||
tm, _ := time.Parse("2006-01-02", s)
|
||||
return tm
|
||||
}
|
||||
Reference in new issue
Block a user