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 `.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 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 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(" element") } openEnd := bytes.Index(original[metaStart:], []byte(">")) if openEnd == -1 { return nil, fmt.Errorf("OPF tag malformed") } metaClose := bytes.Index(original[metaStart:], []byte("")) if metaClose == -1 { return nil, fmt.Errorf("OPF has no 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("")) if end == -1 { break } end += start + len("") 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(`` + "\n") tag := func(name, value string) { if strings.TrimSpace(value) != "" { b.WriteString("\t<" + name + ">" + escXML(value) + "\n") } } tagInt := func(name string, v int) { if v > 0 { b.WriteString("\t<" + name + ">" + strconv.Itoa(v) + "\n") } } tag("Title", strings.TrimSpace(item.Title)) tag("Series", strings.TrimSpace(item.Series.String)) if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 && item.SeriesNumber.Float64 == float64(int(item.SeriesNumber.Float64)) { tagInt("Number", int(item.SeriesNumber.Float64)) } if item.SeriesCount.Valid { tagInt("Count", int(item.SeriesCount.Int32)) } if item.Volume.Valid { tagInt("Volume", int(item.Volume.Int32)) } authors := item.Authors if len(authors) == 0 && item.Author.Valid { authors = []string{item.Author.String} } joinedAuthors := make([]string, 0, len(authors)) for _, a := range authors { a = strings.TrimSpace(a) if a != "" { joinedAuthors = append(joinedAuthors, a) } } tag("Writer", strings.Join(joinedAuthors, ", ")) tag("Summary", item.Description.String) publisher := "" if len(item.Publishers) > 0 { publisher = item.Publishers[0] } else if item.Publisher.Valid { publisher = item.Publisher.String } tag("Publisher", strings.TrimSpace(publisher)) if item.DatePublished.Valid { t := item.DatePublished.Time tagInt("Year", t.Year()) tagInt("Month", int(t.Month())) tagInt("Day", t.Day()) } tag("LanguageISO", strings.TrimSpace(item.Language.String)) if item.WebUrl.Valid { tag("Web", strings.TrimSpace(item.WebUrl.String)) } genre := strings.TrimSpace(item.Genre.String) if genre == "" && len(item.Tags) > 0 { genre = strings.TrimSpace(item.Tags[0]) } tag("Genre", genre) if len(item.Tags) > 0 { cleanTags := make([]string, 0, len(item.Tags)) for _, t := range item.Tags { t = strings.TrimSpace(t) if t != "" { cleanTags = append(cleanTags, t) } } tag("Tags", strings.Join(cleanTags, ", ")) } b.WriteString("\n") return b.String() } // writeCBZComicInfo injects (or replaces) ComicInfo.xml inside the CBZ // — same atomic zip rewrite as the EPUB path, every other entry copied // byte-identical. func (w *MetadataWriter) writeCBZComicInfo(absPath string, item *database.MediaItems) (string, error) { reader, err := zip.OpenReader(absPath) if err != nil { return "", fmt.Errorf("failed to open CBZ: %w", err) } defer reader.Close() newXML := buildComicInfoXML(item) tmpPath := absPath + ".tmp-write" if err := rewriteZipArchive(reader.File, "ComicInfo.xml", []byte(newXML), tmpPath, "ComicInfo.xml", []byte(newXML)); err != nil { os.Remove(tmpPath) return "", err } if err := os.Rename(tmpPath, absPath); err != nil { os.Remove(tmpPath) return "", fmt.Errorf("failed to replace CBZ: %w", err) } return absPath, nil } // ---- OPF generation (mirrors parseOPFContent's read set) ---- // escXML escapes the five XML entities for element and attribute text. 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(`` + "\n") b.WriteString(`` + "\n") b.WriteString(buildOPFMetadataInner(item)) b.WriteString("\n\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" + escXML(title) + "\n") b.WriteString("\tmain\n") } if item.Subtitle.Valid && strings.TrimSpace(item.Subtitle.String) != "" { b.WriteString("\t" + escXML(strings.TrimSpace(item.Subtitle.String)) + "\n") b.WriteString("\tsubtitle\n") } for _, a := range item.Authors { a = strings.TrimSpace(a) if a == "" { continue } b.WriteString("\t" + escXML(a) + "\n") } if len(item.Authors) == 0 && item.Author.Valid && strings.TrimSpace(item.Author.String) != "" { b.WriteString("\t" + escXML(strings.TrimSpace(item.Author.String)) + "\n") } if item.Description.Valid && strings.TrimSpace(item.Description.String) != "" { b.WriteString("\t" + escXML(item.Description.String) + "\n") } for _, p := range item.Publishers { p = strings.TrimSpace(p) if p == "" { continue } b.WriteString("\t" + escXML(p) + "\n") break // parser reads the first } if item.DatePublished.Valid { b.WriteString("\t" + item.DatePublished.Time.Format("2006-01-02") + "\n") } if item.Language.Valid && strings.TrimSpace(item.Language.String) != "" { b.WriteString("\t" + escXML(strings.TrimSpace(item.Language.String)) + "\n") } if item.Isbn.Valid && strings.TrimSpace(item.Isbn.String) != "" { b.WriteString("\t" + escXML(strings.TrimSpace(item.Isbn.String)) + "\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("\turn:uuid:" + escXML(uid) + "\n") } for _, t := range item.Tags { t = strings.TrimSpace(t) if t == "" { continue } b.WriteString("\t" + escXML(t) + "\n") } if item.Series.Valid && strings.TrimSpace(item.Series.String) != "" { b.WriteString("\t\n") if item.SeriesNumber.Valid && item.SeriesNumber.Float64 > 0 { b.WriteString("\t\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) }