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.
912 lines
30 KiB
Go
912 lines
30 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 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.
|
|
func escXML(s string) string {
|
|
return strings.NewReplacer(
|
|
"&", "&",
|
|
"<", "<",
|
|
">", ">",
|
|
`"`, """,
|
|
"'", "'",
|
|
).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)
|
|
}
|