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:
1 parent
61e5f47ec0
commit
eaf1b2c8dd
13 files changed
+1591
-413
No files matched your search
@@ -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
|
||||
|
||||
Reference in new issue
Block a user