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.
100 lines
5.5 KiB
Go
100 lines
5.5 KiB
Go
package router
|
|
|
|
import (
|
|
"bookhoard/internal/handlers"
|
|
)
|
|
|
|
func registerMediaRoutes(cfg *Config) {
|
|
e := cfg.Echo
|
|
|
|
// JWT middleware for protected routes
|
|
jwtMiddleware := createJWTMiddleware(cfg)
|
|
protected := e.Group("/api", jwtMiddleware)
|
|
admin := protected.Group("", handlers.AdminMiddleware)
|
|
|
|
// Media item routes (all authenticated users)
|
|
protected.GET("/media-items", cfg.MediaHandler.ListMediaItems)
|
|
protected.GET("/media-items/:id", cfg.MediaHandler.GetMediaItem)
|
|
// Book download endpoint - same ServeFile flow as /uploads/library-:id/*
|
|
// (JWT + library-visibility gated), addressed by media item ID.
|
|
protected.GET("/media-items/:id/download", cfg.MediaHandler.ServeFile)
|
|
// The format-abstraction chapter list (audio_metadata). Single-file
|
|
// books stream through the existing /uploads Range-serving path;
|
|
// multi-file books additionally expose per-track streaming.
|
|
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
|
|
protected.PUT("/media-items/:id/audio-speed", cfg.MediaHandler.SetAudioSpeed)
|
|
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
|
|
|
|
// Media rating routes (all authenticated users)
|
|
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)
|
|
protected.GET("/media-items/:id/rating", cfg.MediaHandler.GetMediaRating)
|
|
protected.PUT("/media-items/:id/rating", cfg.MediaHandler.UpdateMediaRating)
|
|
protected.DELETE("/media-items/:id/rating", cfg.MediaHandler.DeleteMediaRating)
|
|
|
|
// Media review routes (all authenticated users). Raw markdown in,
|
|
// raw markdown out — rendered only at display sites.
|
|
protected.POST("/media-items/:id/review", cfg.MediaHandler.UpsertMediaReview)
|
|
protected.GET("/media-items/:id/review", cfg.MediaHandler.GetMediaReview)
|
|
protected.PUT("/media-items/:id/review", cfg.MediaHandler.UpsertMediaReview)
|
|
protected.DELETE("/media-items/:id/review", cfg.MediaHandler.DeleteMediaReview)
|
|
|
|
// Progress routes (all authenticated users)
|
|
protected.GET("/media-items/:id/progress", cfg.MediaHandler.GetMediaReadingProgress)
|
|
protected.PUT("/media-items/:id/progress", cfg.MediaHandler.UpdateMediaReadingProgress)
|
|
protected.DELETE("/media-items/:id/progress", cfg.MediaHandler.DeleteMediaReadingProgress)
|
|
|
|
// Notes routes (all authenticated users)
|
|
protected.GET("/media-items/:id/notes", cfg.MediaHandler.GetMediaNotes)
|
|
protected.POST("/media-items/:id/notes", cfg.MediaHandler.CreateMediaNote)
|
|
protected.GET("/media-items/:id/notes/:noteId", cfg.MediaHandler.GetMediaNote)
|
|
protected.PUT("/media-items/:id/notes/:noteId", cfg.MediaHandler.UpdateMediaNote)
|
|
protected.DELETE("/media-items/:id/notes/:noteId", cfg.MediaHandler.DeleteMediaNote)
|
|
|
|
// Highlights routes (all authenticated users)
|
|
protected.GET("/media-items/:id/highlights", cfg.MediaHandler.GetMediaHighlights)
|
|
protected.POST("/media-items/:id/highlights", cfg.MediaHandler.CreateMediaHighlight)
|
|
protected.GET("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.GetMediaHighlight)
|
|
protected.PUT("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.UpdateMediaHighlight)
|
|
protected.DELETE("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.DeleteMediaHighlight)
|
|
|
|
// Bookmark routes (all authenticated users)
|
|
protected.GET("/media-items/:id/bookmarks", cfg.MediaHandler.GetMediaBookmarks)
|
|
protected.POST("/media-items/:id/bookmarks", cfg.MediaHandler.CreateMediaBookmark)
|
|
protected.PUT("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.UpdateMediaBookmark)
|
|
protected.DELETE("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.DeleteMediaBookmark)
|
|
|
|
// Deleted-annotation history (all authenticated users): tombstoned
|
|
// highlights/notes/bookmarks restorable or permanently removable from the
|
|
// book page's "recently deleted" list.
|
|
protected.GET("/media-items/:id/annotations/deleted", cfg.MediaHandler.GetDeletedAnnotations)
|
|
protected.POST("/media-items/:id/annotations/:annotationId/restore", cfg.MediaHandler.RestoreDeletedAnnotation)
|
|
protected.DELETE("/media-items/:id/annotations/:annotationId", cfg.MediaHandler.PurgeDeletedAnnotation)
|
|
|
|
// Admin-only media routes
|
|
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
|
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
|
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
|
|
admin.POST("/media-items/:id/write-metadata", cfg.MediaHandler.WriteMetadataToFile)
|
|
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
|
|
admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
|
|
admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem)
|
|
admin.POST("/media-items/:id/merge", cfg.MediaHandler.MergeArchivedMediaItem)
|
|
|
|
// Shelf management (protected)
|
|
protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf)
|
|
protected.GET("/devices/:id/shelves", cfg.MediaHandler.GetShelf)
|
|
protected.DELETE("/devices/:id/shelves", cfg.MediaHandler.RemoveFromShelf)
|
|
protected.DELETE("/devices/:id/shelves/clear", cfg.MediaHandler.ClearShelf)
|
|
|
|
// Bulk media operations (protected)
|
|
mediaItems := protected.Group("/media-items")
|
|
mediaItems.POST("/bulk-delete", cfg.MediaHandler.HandleBulkDelete)
|
|
mediaItems.POST("/bulk-update", cfg.MediaHandler.HandleBulkUpdate)
|
|
|
|
// File serving - authenticated (registered on Echo to avoid /api prefix)
|
|
// Create a group with JWT middleware for routes outside /api
|
|
authenticated := e.Group("", createJWTMiddleware(cfg))
|
|
// Note: Must be registered LAST as it's a wildcard route
|
|
authenticated.GET("/uploads/library-:id/*", cfg.MediaHandler.ServeFile)
|
|
}
|