feat(metadata): write-to-file — embedded EPUB OPF + Calibre sidecars

Phase 5a of the metadata-writer feature: the book editor gains a
"Write to file" action (admin) that persists the item's CURRENT
database metadata into the artifact on disk, so the library stays
portable to Calibre and other apps — the thing Save alone can't do.

Dispatch (services/metadata_writer.go): EPUB/KEPUB gets its embedded
OPF spliced in place (fresh dc:* block generated inside the ORIGINAL
metadata open tag; manifest/spine/guide untouched; every other zip
entry copied byte-identical in original order so mimetype stays first
and STORED; original dc:identifiers preserved — the package's
unique-identifier must keep resolving). Everything else takes a
Calibre-style sidecar: metadata.opf when the book is ALONE in its
directory (Calibre's own convention — round-trips through Calibre
too), <basename>.opf when siblings share the folder, so one book's
sidecar can never poison dir-mates' scans (the scanner now reads both,
basename first).

Safety contract: temp file + atomic rename — the original is never
truncated on failure; the generated OPF is unit-tested to ROUND-TRIP
through the scanner's own parser (write side and read side can never
disagree); metadata_overrides are deliberately kept (file and DB agree
after the write; overrides still guard against stale sidecars). The
endpoint re-extracts inline (RescanMediaItem) and reports rescan
failure separately — a failed rescan doesn't undo the write. SHA-256
changes are inherent and documented for users (Kobo/KOReader re-key).

UI: the metadata editor modal carries the file path as a data
attribute; the button confirms before mutating, labels the target
(embedded vs sidecar) and toasts the outcome. Integration tests pin
the 403, missing-file error, EPUB embed + post-write rescan title, the
lone-book metadata.opf case, and the shared-dir <basename>.opf case
with no dir-scoped litter.
This commit is contained in:
John O'Keefe committed 2026-10-27 15:36:43 -04:00
1 parent 61e5f47ec0
commit eaf1b2c8dd
13 files changed
+1591 -413

No files matched your search

+16 -15
View File
@@ -1773,23 +1773,24 @@ func applyTitleDerivedSeries(metadata *MediaMetadata) {
// extractCalibreSidecar checks for and parses a Calibre metadata.opf sidecar file
func (s *MediaScanner) extractCalibreSidecar(path string) *MediaMetadata {
// Get directory of media file
// Per-file sidecar FIRST (<basename>.opf, the writer's multi-book
// safe name), then Calibre's dir-scoped metadata.opf.
dir := filepath.Dir(path)
opfPath := filepath.Join(dir, "metadata.opf")
// Check if sidecar exists
if _, err := os.Stat(opfPath); os.IsNotExist(err) {
return nil // No sidecar, not an error
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
for _, name := range []string{base + ".opf", "metadata.opf"} {
opfPath := filepath.Join(dir, name)
if _, err := os.Stat(opfPath); err != nil {
continue
}
// Parse sidecar
metadata, err := s.parseCalibreMetadataOPF(opfPath)
if err != nil {
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
continue // fall back to the next candidate / embedded
}
return metadata
}
// Parse sidecar
metadata, err := s.parseCalibreMetadataOPF(opfPath)
if err != nil {
fmt.Printf("Warning: failed to parse Calibre metadata.opf: %v\n", err)
return nil // Parsing failed, fall back to embedded
}
return metadata
return nil
}
// applyEmbeddedCoverFallback extracts a cover from the media file itself when