Files
bookhoard/internal/services/metadata_writer.go
T
John O'Keefe fb1645c90c fix(metadata): m4b text-track fallback; in-app confirm; save-first write
Three defects from real-world use:

- The m4b rewrite assumed every stream survives -map 0 -c copy; files
  carrying text-ish tracks (the movenc 'text' fourcc) fail the ipod
  muxer with "Tag text incompatible with output codec id" — exactly the
  error a write attempt hit in the field. The rewrite now attempts full
  fidelity first, then retries mapping audio + attached pictures only
  (-dn -sn): chapters live in the container header (-map_chapters 0)
  so they survive; every attempt still ffprobe-validates before the
  atomic rename and the original is never touched on total failure.

- The browser confirm() is replaced by an in-app dialog
  (WriteToFileConfirmDialog, Alpine + templ — no native dialogs).

- "Write to file" now SAVES the pending editor edits first (persisted
  through the same PUT the Save button uses), then writes the file
  from those DB values, and closes the editor + refreshes the page
  exactly like Save — previously the endpoint wrote the LAST-SAVED DB
  values, so edits sitting in the modal never reached the file, and
  the page didn't visibly update.

Test coverage per the owner's bar: EVERY single-file container (MP3,
FLAC, M4A, M4B) gets its own runtime-generated fixture, gets written,
and the FILE's metadata is then verified independently — ffprobe reads
the rewritten container and every mapped key is asserted per format,
including the subtitle round-trip split (MP3/FLAC yes, M4A/B no) — and
the EPUB/CBZ/sidecar assertions now PARSE the written XML (dc titles/
creators, ComicInfo fields, calibre meta) instead of substring
matching. A smart-genre unit test also pins the mixed-blob contract: a
recognized shelf wins the genre column even when demoted format words
ride along in the same blob.
2026-10-27 19:05:23 -04:00

932 lines
31 KiB
Go

package services
import (
"archive/zip"
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"math"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
// MetadataWriter persists the item's CURRENT database metadata back into
// the artifact on disk, so the library stays portable (Calibre, phone
// players, other servers). 2026-10-27 feature (user request): embed into
// formats that carry metadata, write a Calibre-style sidecar for those
// that don't.
//
// Safety contract:
// - temp file + atomic rename; the original is NEVER truncated on
// failure (a failed write must not destroy a book)
// - the write mirrors what the scanner's read chain parses
// (parseOPFContent), so a write + rescan round-trips
// - metadata_overrides are left untouched: after a successful write
// the file and the DB agree, and the overrides keep stale sidecars
// from clobbering later rescans
//
// Consequences that are inherent to editing files in place (documented
// in docs/user/calibre-integration.md): the mtime change makes the
// watcher re-extract (wanted), and the SHA-256 changes (dedup/move
// detection re-keys; Kobo device sidecars re-key on next sync).
// MetadataWriteTarget names where a format's metadata landed.
type MetadataWriteTarget string
const (
// MetadataTargetEmbeddedOPF: the OPF inside the EPUB was rewritten.
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).
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 {
Target MetadataWriteTarget `json:"target"`
Path string `json:"path"` // absolute path of the artifact written
Sidecar bool `json:"sidecar"`
FieldsWritten []string `json:"fields_written"`
}
// 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 {
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:
// CBR/CB7 (no in-place rewrite) and every other format take
// the sidecar.
return MetadataTargetSidecarOPF
}
}
// MetadataWriter carries the dependencies for file-writing operations.
type MetadataWriter struct {
db *database.Queries
libraries *LibraryService
}
func NewMetadataWriter(db *database.Queries) *MetadataWriter {
return &MetadataWriter{db: db, libraries: NewLibraryService(db)}
}
// WriteMetadataToFile dispatches on the item's format and persists the
// current DB values into the artifact on disk.
func (w *MetadataWriter) WriteMetadataToFile(ctx context.Context, itemID uuid.UUID) (*MetadataWriteResult, error) {
item, err := w.db.GetMediaItem(ctx, pgtype.UUID{Bytes: itemID, Valid: true})
if err != nil {
return nil, fmt.Errorf("media item not found: %w", err)
}
absPath, err := w.libraries.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
if err != nil {
return nil, fmt.Errorf("cannot resolve library folder: %w", err)
}
if _, err := os.Stat(absPath); err != nil {
return nil, fmt.Errorf("media file missing on disk: %w", err)
}
switch MetadataWriteTargetFor(filepath.Ext(absPath)) {
case MetadataTargetEmbeddedOPF:
path, err := w.writeEPUBMetadata(absPath, &item)
if err != nil {
return nil, err
}
return &MetadataWriteResult{
Target: MetadataTargetEmbeddedOPF,
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 {
return nil, err
}
return &MetadataWriteResult{
Target: MetadataTargetSidecarOPF,
Path: path,
Sidecar: true,
FieldsWritten: metadataFieldNames(&item),
}, nil
}
}
// writeSidecarOPF writes a Calibre-style OPF next to the media file.
// Naming: `metadata.opf` (Calibre's own convention) when the item is
// the only book in its directory; otherwise `<basename>.opf` so one
// book's sidecar can never poison its dir-mates' scans. The scanner
// reads both (basename first).
func (w *MetadataWriter) writeSidecarOPF(ctx context.Context, absPath string, item *database.MediaItems) (string, error) {
dir := filepath.Dir(absPath)
base := strings.TrimSuffix(filepath.Base(absPath), filepath.Ext(absPath))
others, err := w.countSiblingBooks(ctx, item)
if err != nil {
return "", fmt.Errorf("cannot check sibling books: %w", err)
}
name := "metadata.opf"
if others > 0 {
name = base + ".opf"
}
sidecarPath := filepath.Join(dir, name)
doc := buildSidecarOPFXML(item)
if err := atomicWrite(sidecarPath, []byte(doc)); err != nil {
return "", fmt.Errorf("failed to write sidecar: %w", err)
}
return sidecarPath, nil
}
// countSiblingBooks counts OTHER non-archived items that live in the
// same directory of the same library — the guard against writing a
// dir-scoped metadata.opf over a multi-book folder.
func (w *MetadataWriter) countSiblingBooks(ctx context.Context, item *database.MediaItems) (int, error) {
dir := filepath.ToSlash(filepath.Dir(item.FilePath))
siblings, err := w.db.CountOtherMediaItemsInDir(ctx, database.CountOtherMediaItemsInDirParams{
LibraryID: item.LibraryID,
ID: item.ID,
DirPrefix: pgtype.Text{String: dir + "/", Valid: true},
})
if err != nil {
return 0, err
}
return int(siblings), nil
}
// writeEPUBMetadata splices a freshly generated <metadata> block into
// the EPUB's existing OPF (manifest/spine/guide preserved untouched)
// and rewrites the zip atomically: every other entry is copied
// byte-identical, in original order with original compression — the
// mimetype entry must stay first and STORED.
func (w *MetadataWriter) writeEPUBMetadata(absPath string, item *database.MediaItems) (string, error) {
reader, err := zip.OpenReader(absPath)
if err != nil {
return "", fmt.Errorf("failed to open EPUB: %w", err)
}
defer reader.Close()
opfName := findOPFPathInZip(reader.File)
if opfName == "" {
return "", fmt.Errorf("EPUB has no OPF (container.xml missing rootfile)")
}
newOPF, err := spliceOPFMetadata(reader.File, opfName, item)
if err != nil {
return "", err
}
tmpPath := absPath + ".tmp-write"
if err := rewriteEPUBZip(reader.File, opfName, newOPF, tmpPath); err != nil {
os.Remove(tmpPath)
return "", err
}
if err := os.Rename(tmpPath, absPath); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("failed to replace EPUB: %w", err)
}
return absPath, nil
}
// spliceOPFMetadata returns the OPF with its <metadata> inner XML
// replaced. The ORIGINAL opening tag (with its namespaces and
// unique-identifier) is preserved so manifest/spine id references keep
// resolving.
func spliceOPFMetadata(entries []*zip.File, opfName string, item *database.MediaItems) ([]byte, error) {
var opfEntry *zip.File
for _, f := range entries {
if f.Name == opfName {
opfEntry = f
break
}
}
if opfEntry == nil {
return nil, fmt.Errorf("OPF %q not found in EPUB", opfName)
}
rc, err := opfEntry.Open()
if err != nil {
return nil, fmt.Errorf("failed to open OPF: %w", err)
}
original, err := io.ReadAll(rc)
closeErr := rc.Close()
if err != nil {
return nil, fmt.Errorf("failed to read OPF: %w", err)
}
if closeErr != nil {
return nil, fmt.Errorf("failed to close OPF: %w", closeErr)
}
metaStart := bytes.Index(original, []byte("<metadata"))
if metaStart == -1 {
return nil, fmt.Errorf("OPF has no <metadata> element")
}
openEnd := bytes.Index(original[metaStart:], []byte(">"))
if openEnd == -1 {
return nil, fmt.Errorf("OPF <metadata> tag malformed")
}
metaClose := bytes.Index(original[metaStart:], []byte("</metadata>"))
if metaClose == -1 {
return nil, fmt.Errorf("OPF has no </metadata> close")
}
openTag := original[metaStart : metaStart+openEnd+1]
inner := buildOPFMetadataInner(item) + originalIdentifiers(original[metaStart:metaStart+metaClose])
out := bytes.NewBuffer(nil)
out.Write(original[:metaStart])
out.Write(openTag)
out.WriteString("\n")
out.WriteString(inner)
out.Write(original[metaStart+metaClose:])
return out.Bytes(), nil
}
// originalIdentifiers extracts the old metadata's dc:identifier
// elements verbatim. The package's unique-identifier attribute
// references one of them, so replacing the metadata block must keep
// them (generated fields come FIRST, so the parser's first-wins reads
// the fresh ISBN; the originals remain for identifier stability).
func originalIdentifiers(oldMetadata []byte) string {
var b strings.Builder
rest := oldMetadata
for {
start := bytes.Index(rest, []byte("<dc:identifier"))
if start == -1 {
break
}
end := bytes.Index(rest[start:], []byte("</dc:identifier>"))
if end == -1 {
break
}
end += start + len("</dc:identifier>")
b.Write(rest[start:end])
b.WriteString("\n")
rest = rest[end:]
}
return b.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 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 == name {
// Sizes/CRC recomputed by the writer for the new content.
header.CompressedSize64 = 0
header.UncompressedSize64 = 0
header.CompressedSize = 0
header.UncompressedSize = 0
header.CRC32 = 0
target, err = writer.CreateHeader(&header)
if err != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to stage substituted entry: %w", err)
}
if _, err := target.Write(content); err != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to write substituted entry: %w", err)
}
substituted = true
continue
}
target, err = writer.CreateHeader(&header)
if err != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to stage entry %s: %w", f.Name, err)
}
rc, openErr := f.Open()
if openErr != nil {
writer.Close()
out.Close()
return fmt.Errorf("failed to read entry %s: %w", f.Name, openErr)
}
if _, err := io.Copy(target, rc); err != nil {
rc.Close()
writer.Close()
out.Close()
return fmt.Errorf("failed to copy entry %s: %w", f.Name, err)
}
if err := rc.Close(); err != nil {
writer.Close()
out.Close()
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 archive: %w", err)
}
if err := out.Sync(); err != nil {
out.Close()
return fmt.Errorf("failed to sync archive: %w", err)
}
return out.Close()
}
// atomicWrite writes via temp file + rename in the SAME directory so a
// crash or a full disk can never leave a truncated book (or sidecar)
// behind.
func atomicWrite(path string, data []byte) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".bookhoard-write-*")
if err != nil {
return err
}
tmpPath := tmp.Name()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return err
}
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_metadata 0` preserves existing
// global tags, and `-map_chapters 0` keeps the m4b chapter list
// (chapters live in the header, not a stream). Attempt 1 copies every
// stream; attempt 2 — for real-world files carrying text/subtitle/data
// tracks the ipod muxer rejects ("Tag text incompatible with output
// codec id ...") — maps audio + attached pictures only. Every attempt
// is ffprobe-VALIDATED (duration within tolerance of the source)
// BEFORE the atomic rename; a failure never touches the original.
func (w *MetadataWriter) writeAudioTags(absPath string, item *database.MediaItems) (string, error) {
info, err := os.Stat(absPath)
if err != nil {
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
// Shared args; each attempt appends its own stream mapping.
base := []string{"-hide_banner", "-loglevel", "error", "-y",
"-i", absPath,
"-map_metadata", "0", "-map_chapters", "0", "-c", "copy",
}
for _, kv := range audioTagArgs(item, ext) {
base = append(base, "-metadata", kv[0]+"="+kv[1])
}
// Stream maps, best fidelity first.
streamMaps := [][]string{
{"-map", "0"}, // every stream (covers, chapters-as-streams)
// Audio + attached pictures; drop text/subtitle/data tracks —
// the ipod muxer refuses mov_text-style streams outright.
{"-map", "0:a", "-map", "0:t?", "-dn", "-sn"},
}
var lastErr error
for i, streamMap := range streamMaps {
args := append(append([]string{}, base...), streamMap...)
args = append(args, tmpPath)
if out, err := exec.Command("ffmpeg", args...).CombinedOutput(); err != nil {
os.Remove(tmpPath)
lastErr = fmt.Errorf("ffmpeg tag rewrite failed (attempt %d): %s", i+1, strings.TrimSpace(string(out)))
continue
}
// Validation gate: the rewritten container must still hold the
// same audio (duration within 1s or 0.5%, whichever is looser).
outDuration := probeDurationSeconds(tmpPath)
if outDuration <= 0 {
os.Remove(tmpPath)
lastErr = fmt.Errorf("rewritten audio failed validation (ffprobe read no duration)")
continue
}
if srcDuration > 0 {
tolerance := math.Max(1.0, srcDuration*0.005)
if math.Abs(outDuration-srcDuration) > tolerance {
os.Remove(tmpPath)
lastErr = fmt.Errorf("rewritten audio failed validation (duration %.2fs → %.2fs)", srcDuration, outDuration)
continue
}
}
if err := os.Rename(tmpPath, absPath); err != nil {
os.Remove(tmpPath)
return "", fmt.Errorf("failed to replace audio file: %w", err)
}
return absPath, nil
}
return "", lastErr
}
// ---- 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.
func escXML(s string) string {
return strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
).Replace(s)
}
// buildSidecarOPFXML renders a complete Calibre-compatible package
// document for a sidecar.
func buildSidecarOPFXML(item *database.MediaItems) string {
var b strings.Builder
b.WriteString(xml.Header)
b.WriteString(`<package version="2.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="bookhoard-uuid">` + "\n")
b.WriteString(`<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">` + "\n")
b.WriteString(buildOPFMetadataInner(item))
b.WriteString("</metadata>\n</package>\n")
return b.String()
}
// buildOPFMetadataInner renders the dc:* elements for the item's
// current values — exactly the fields parseOPFContent reads back
// (title/subtitle via title-type, creators, subjects, description,
// publisher, date, language, isbn identifier, calibre:series metas).
// Empty DB values simply omit their element: a sidecar absent-field
// means "no data", never "delete this from the file".
func buildOPFMetadataInner(item *database.MediaItems) string {
var b strings.Builder
title := strings.TrimSpace(item.Title)
if title != "" {
b.WriteString("\t<dc:title id=\"tmain\">" + escXML(title) + "</dc:title>\n")
b.WriteString("\t<meta refines=\"#tmain\" property=\"title-type\">main</meta>\n")
}
if item.Subtitle.Valid && strings.TrimSpace(item.Subtitle.String) != "" {
b.WriteString("\t<dc:title id=\"tsub\">" + escXML(strings.TrimSpace(item.Subtitle.String)) + "</dc:title>\n")
b.WriteString("\t<meta refines=\"#tsub\" property=\"title-type\">subtitle</meta>\n")
}
for _, a := range item.Authors {
a = strings.TrimSpace(a)
if a == "" {
continue
}
b.WriteString("\t<dc:creator opf:role=\"aut\">" + escXML(a) + "</dc:creator>\n")
}
if len(item.Authors) == 0 && item.Author.Valid && strings.TrimSpace(item.Author.String) != "" {
b.WriteString("\t<dc:creator opf:role=\"aut\">" + escXML(strings.TrimSpace(item.Author.String)) + "</dc:creator>\n")
}
if item.Description.Valid && strings.TrimSpace(item.Description.String) != "" {
b.WriteString("\t<dc:description>" + escXML(item.Description.String) + "</dc:description>\n")
}
for _, p := range item.Publishers {
p = strings.TrimSpace(p)
if p == "" {
continue
}
b.WriteString("\t<dc:publisher>" + escXML(p) + "</dc:publisher>\n")
break // parser reads the first
}
if item.DatePublished.Valid {
b.WriteString("\t<dc:date>" + item.DatePublished.Time.Format("2006-01-02") + "</dc:date>\n")
}
if item.Language.Valid && strings.TrimSpace(item.Language.String) != "" {
b.WriteString("\t<dc:language>" + escXML(strings.TrimSpace(item.Language.String)) + "</dc:language>\n")
}
if item.Isbn.Valid && strings.TrimSpace(item.Isbn.String) != "" {
b.WriteString("\t<dc:identifier opf:scheme=\"ISBN\">" + escXML(strings.TrimSpace(item.Isbn.String)) + "</dc:identifier>\n")
}
// The package's unique-identifier needs a matching element: the
// scan's OPF uuid when known, else the item id (the parser files
// urn:uuid under hash info, never metadata).
uid := ""
if item.OpfUuid.Valid {
uid = strings.TrimSpace(item.OpfUuid.String)
}
if uid == "" && item.ID.Valid {
uid = uuid.UUID(item.ID.Bytes).String()
}
if uid != "" {
b.WriteString("\t<dc:identifier id=\"bookhoard-uuid\">urn:uuid:" + escXML(uid) + "</dc:identifier>\n")
}
for _, t := range item.Tags {
t = strings.TrimSpace(t)
if t == "" {
continue
}
b.WriteString("\t<dc:subject>" + escXML(t) + "</dc:subject>\n")
}
if item.Series.Valid && strings.TrimSpace(item.Series.String) != "" {
b.WriteString("\t<meta name=\"calibre:series\" content=\"" + escXML(item.Series.String) + "\"/>\n")
if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 {
b.WriteString("\t<meta name=\"calibre:series_index\" content=\"" + strconvFormatFloat(item.SeriesNumber.Float64) + "\"/>\n")
}
}
return b.String()
}
// metadataFieldNames lists the DB fields a write mirrors — response
// contract for the UI ("what got written").
func metadataFieldNames(item *database.MediaItems) []string {
fields := []string{"title"}
if item.Subtitle.Valid && item.Subtitle.String != "" {
fields = append(fields, "subtitle")
}
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.DatePublished.Valid {
fields = append(fields, "date_published")
}
if item.Language.Valid && item.Language.String != "" {
fields = append(fields, "language")
}
if item.Isbn.Valid && item.Isbn.String != "" {
fields = append(fields, "isbn")
}
if len(item.Tags) > 0 {
fields = append(fields, "tags")
}
if item.Series.Valid && item.Series.String != "" {
fields = append(fields, "series")
}
return fields
}
// strconvFormatFloat renders series indexes without trailing noise
// (3 → "3", 3.5 → "3.5").
func strconvFormatFloat(f float64) string {
return fmt.Sprintf("%g", f)
}