From 298330a3a8475943444653e329d69487f8255b4e Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 11 Sep 2026 23:08:08 -0400 Subject: [PATCH] feat(api): add POST /api/media-items/:id/rescan endpoint Admin-only endpoint that re-extracts metadata and cover art for a single book from the file on disk via MediaScanner.RescanMediaItem and returns the updated media item. Normal library scans skip unchanged files, so this gives a targeted way to backfill covers for previously imported books. Includes a Bruno request alongside the existing media-items collection. --- bruno/media-items/Rescan Media Item.yml | 35 ++++++++++++++++++++++++ internal/handlers/media.go | 36 +++++++++++++++++++++++++ internal/router/media.go | 1 + 3 files changed, 72 insertions(+) create mode 100644 bruno/media-items/Rescan Media Item.yml diff --git a/bruno/media-items/Rescan Media Item.yml b/bruno/media-items/Rescan Media Item.yml new file mode 100644 index 0000000..9bbf4e2 --- /dev/null +++ b/bruno/media-items/Rescan Media Item.yml @@ -0,0 +1,35 @@ +info: + name: Rescan Media Item + type: http + seq: 9 + +http: + method: POST + url: "{{base_url}}/api/media-items/{{media_item_id}}/rescan" + headers: + - name: "" + value: application/json + auth: inherit + +runtime: + scripts: + - type: tests + code: |- + test_rescan_media_item_success(status, headers, body) { + if (status !== 200) { + throw new Error("Expected status 200, got " + status); + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 + +docs: |- + ## Rescan Media Item + + Re-extracts metadata and cover art for a single media item from the file on disk. + + **Method:** POST + + **Endpoint:** /api/media-items/{id}/rescan diff --git a/internal/handlers/media.go b/internal/handlers/media.go index c31bee2..e899940 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -1309,6 +1309,42 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error { return c.JSON(http.StatusOK, item) } +// RescanMediaItem handles POST /api/media-items/:id/rescan (admin only) +func (mh *MediaHandler) RescanMediaItem(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()}) + } + + scanner := services.NewMediaScanner(mh.db) + defer scanner.Close() + + if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, item) +} + // DeleteMediaItem handles DELETE /api/media-items/:id (admin only) func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error { user := MustGetAuthenticatedUser(c) diff --git a/internal/router/media.go b/internal/router/media.go index 6998b80..439ff41 100644 --- a/internal/router/media.go +++ b/internal/router/media.go @@ -60,6 +60,7 @@ func registerMediaRoutes(cfg *Config) { // 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.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem) // Shelf management (protected)