package services import ( "archive/zip" "bytes" "context" "encoding/xml" "fmt" "io" "os" "path/filepath" "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; audio until phase 5b). MetadataTargetSidecarOPF MetadataWriteTarget = "sidecar-opf" ) // 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 { switch strings.ToLower(strings.TrimSpace(ext)) { case ".epub", ".kepub": return MetadataTargetEmbeddedOPF default: // 5b (audio tags via ffmpeg) and 5c (CBZ ComicInfo.xml) will // claim their formats here; everything else takes 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 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 { out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) if err != nil { return fmt.Errorf("failed to create temp EPUB: %w", err) } writer := zip.NewWriter(out) for _, f := range entries { header := f.FileHeader header.Flags &= ^uint16(0x8) // drop data-descriptor flag; sizes are known upfront var target io.Writer if f.Name == opfName { // 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 OPF entry: %w", err) } if _, err := target.Write(newOPF); err != nil { writer.Close() out.Close() return fmt.Errorf("failed to write OPF entry: %w", err) } 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 err := writer.Close(); err != nil { out.Close() return fmt.Errorf("failed to finalize EPUB: %w", err) } if err := out.Sync(); err != nil { out.Close() return fmt.Errorf("failed to sync EPUB: %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 } // ---- 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) }