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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user