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

+51
View File
@@ -1801,6 +1801,57 @@ func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
return c.JSON(http.StatusOK, item)
}
// WriteMetadataToFile handles POST /api/media-items/:id/write-metadata
// (admin only). Persists the item's CURRENT database metadata into the
// artifact on disk — embedded OPF for EPUBs, a Calibre-style sidecar
// for formats that can't carry metadata (2026-10-27 feature) — then
// re-extracts from the file so the scan-derived columns agree with
// what was written. metadata_overrides are NOT reset: after the write,
// file and DB agree, and the overrides keep stale sidecars from
// clobbering future rescans.
func (mh *MediaHandler) WriteMetadataToFile(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
mediaID := c.Param("id")
mediaUUID, err := uuid.Parse(mediaID)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
writer := services.NewMetadataWriter(mh.db)
result, err := writer.WriteMetadataToFile(c.Request().Context(), mediaUUID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
// Re-extract so the scan pipeline sees the file we just wrote. A
// rescan failure doesn't undo the write — it surfaces separately.
rescanStatus := "ok"
scanner := services.NewMediaScanner(mh.db)
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, false); err != nil {
rescanStatus = "error: " + err.Error()
}
defer scanner.Close()
return c.JSON(http.StatusOK, map[string]interface{}{
"write": result,
"rescan": rescanStatus,
"metadata_target": string(result.Target),
"fields_written": result.FieldsWritten,
})
}
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)