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.
This commit is contained in:
2026-09-11 23:08:20 -04:00
parent 0614795ca2
commit 298330a3a8
3 changed files with 72 additions and 0 deletions
+35
View File
@@ -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
+36
View File
@@ -1309,6 +1309,42 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
return c.JSON(http.StatusOK, item) 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) // DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error { func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c) user := MustGetAuthenticatedUser(c)
+1
View File
@@ -60,6 +60,7 @@ func registerMediaRoutes(cfg *Config) {
// Admin-only media routes // Admin-only media routes
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem) admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem) 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) admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
// Shelf management (protected) // Shelf management (protected)