3 Commits
Author SHA1 Message Date
john-okeefe da1f689263 feat(ui): add Rescan button to Edit Metadata dialog
Release / build-and-push (push) Successful in 2m50s
Adds a Rescan button to the MetadataEditorModal footer that POSTs to the
new per-book rescan endpoint, with rescanning state (disabled + spinner,
matching the existing Mark Read pattern), success/error toasts, and a
page reload to pick up the refreshed cover and metadata.
2026-09-11 23:08:20 -04:00
john-okeefe 298330a3a8 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.
2026-09-11 23:08:20 -04:00
john-okeefe 0614795ca2 feat(scanner): render PDF first page as cover fallback via pdftoppm
extractPDFCover previously only saved embedded raster images from page 1,
so vector/text-first-page PDFs (e.g. InDesign exports like Data Structures
the Fun Way) ended up with no cover and a dashboard placeholder. When no
embedded image is found it now falls back to rendering page 1 with
pdftoppm (poppler-utils), saving the same {pdf}.cover.jpg sidecar.

Also adds MediaScanner.RescanMediaItem, which re-extracts metadata for a
single media item (resolving its on-disk path from library folders) so
previously imported books can backfill covers without a full force rescan.

Dockerfile installs poppler-utils in the final and test-runner stages.
2026-09-11 23:08:20 -04:00
8 changed files with 250 additions and 45 deletions
+2 -2
View File
@@ -43,7 +43,7 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
# This stage is ONLY used for running tests, never deployed to production
FROM golang:1.26-alpine AS test-runner
RUN apk --no-cache add ca-certificates curl
RUN apk --no-cache add ca-certificates curl poppler-utils
WORKDIR /app
@@ -65,7 +65,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1",
# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates curl
RUN apk --no-cache add ca-certificates curl poppler-utils
# Install kepubify for EPUB→KEPUB conversion
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
+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)
}
// 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)
+1
View File
@@ -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)
+101 -17
View File
@@ -23,6 +23,7 @@ import (
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
@@ -2078,17 +2079,10 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
// Use pdfcpu API to extract images from first page
// ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error
err = pdfcpuapi.ExtractImagesFile(pdfPath, tmpDir, []string{"1"}, nil)
if err != nil {
// No images found or extraction failed - this is OK, just return empty
return "", nil
}
if err == nil {
// Check for extracted images in the temp directory
entries, err := os.ReadDir(tmpDir)
if err != nil || len(entries) == 0 {
return "", nil
}
if err == nil && len(entries) > 0 {
// Find the largest image (likely the cover)
var largestImage string
var largestSize int64
@@ -2111,16 +2105,10 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
}
}
if largestImage == "" {
return "", nil
}
if largestImage != "" {
// Read the image
imageData, err := os.ReadFile(largestImage)
if err != nil || len(imageData) == 0 {
return "", nil
}
if err == nil && len(imageData) > 0 {
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
coverPath := pdfPath + ".cover.jpg"
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
@@ -2129,6 +2117,57 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
return coverPath, nil
}
}
}
}
// No embedded raster cover found (e.g. vector/text first page) - fall back
// to rendering the first page with pdftoppm (poppler-utils).
return s.renderPDFCoverPage(pdfPath), nil
}
// renderPDFCoverPage renders the first page of a PDF file to a JPEG image
// using pdftoppm. It saves the cover next to the PDF ({pdf_path}.cover.jpg).
// Returns the path to the saved cover, or empty string if rendering failed.
func (s *MediaScanner) renderPDFCoverPage(pdfPath string) string {
if _, err := exec.LookPath("pdftoppm"); err != nil {
fmt.Printf("Warning: pdftoppm not available, skipping PDF cover render for %s\n", pdfPath)
return ""
}
tmpDir, err := os.MkdirTemp("", "pdf-render-")
if err != nil {
fmt.Printf("Warning: failed to create temp dir for PDF cover render %s: %v\n", pdfPath, err)
return ""
}
defer func() {
if err := os.RemoveAll(tmpDir); err != nil {
fmt.Printf("Warning: failed to remove temp directory %s: %v\n", tmpDir, err)
}
}()
outPrefix := filepath.Join(tmpDir, "cover")
cmd := exec.Command("pdftoppm", "-jpeg", "-f", "1", "-l", "1", "-singlefile", "-r", "150", pdfPath, outPrefix)
if output, err := cmd.CombinedOutput(); err != nil {
fmt.Printf("Warning: failed to render PDF cover from %s: %v, output: %s\n", pdfPath, err, string(output))
return ""
}
imageData, err := os.ReadFile(outPrefix + ".jpg")
if err != nil || len(imageData) < 1000 {
fmt.Printf("Warning: PDF cover render produced no usable image for %s\n", pdfPath)
return ""
}
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
coverPath := pdfPath + ".cover.jpg"
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
fmt.Printf("Warning: failed to write rendered PDF cover for %s: %v\n", pdfPath, err)
return ""
}
return coverPath
}
// ComicInfo represents metadata from ComicInfo.xml
type ComicInfo struct {
@@ -2618,6 +2657,51 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
return err
}
// RescanMediaItem re-extracts metadata for a single media item and updates it.
// It is the per-book rescan used by the Edit Metadata dialog and backfills
// covers for items imported before the PDF render fallback existed.
func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.UUID) error {
item, err := s.db.GetMediaItem(ctx, mediaItemID)
if err != nil {
return fmt.Errorf("media item not found: %w", err)
}
folders, err := s.db.GetLibraryFolders(ctx, item.LibraryID)
if err != nil || len(folders) == 0 {
return fmt.Errorf("no library folders found for library")
}
folderPaths := make([]string, 0, len(folders))
for _, folder := range folders {
folderPaths = append(folderPaths, folder.FolderPath)
}
s.folders = folderPaths
var fullPath string
for _, folder := range folders {
candidate := filepath.Join(folder.FolderPath, item.FilePath)
if _, err := os.Stat(candidate); err == nil {
fullPath = candidate
break
}
}
if fullPath == "" {
return fmt.Errorf("media file not found on disk: %s", item.FilePath)
}
info, err := os.Stat(fullPath)
if err != nil {
return fmt.Errorf("failed to stat media file: %w", err)
}
if err := s.updateMediaItem(ctx, mediaItemID, fullPath, info); err != nil {
return fmt.Errorf("failed to update media item: %w", err)
}
s.recomputeHashInfo(ctx, mediaItemID, item.LibraryID, fullPath)
return nil
}
func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string, libraryID pgtype.UUID) (database.MediaItems, error) {
return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
FilePath: s.getRelativePath(filePath),
+14 -1
View File
@@ -609,7 +609,19 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</div>
</div>
</div>
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
<div class="flex items-center justify-between p-6 border-t flex-shrink-0" style="border-color: var(--border);">
<button
@click="rescanBook()"
:disabled="rescanning"
class="btn btn-secondary"
>
<span x-show="!rescanning" class="inline-flex items-center gap-2">@Icon("sync", "h-4 w-4")<span>Rescan</span></span>
<svg x-show="rescanning" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
<div class="flex space-x-3">
<button
@click="hideMetadataEditor()"
class="btn btn-secondary"
@@ -626,4 +638,5 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
</div>
</div>
</div>
</div>
}
+10 -2
View File
@@ -1336,7 +1336,15 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" readonly class=\"input opacity-60\"></div></div></div></div></div></div><div class=\"flex justify-end space-x-3 p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 126, "\" readonly class=\"input opacity-60\"></div></div></div></div></div></div><div class=\"flex items-center justify-between p-6 border-t flex-shrink-0\" style=\"border-color: var(--border);\"><button @click=\"rescanBook()\" :disabled=\"rescanning\" class=\"btn btn-secondary\"><span x-show=\"!rescanning\" class=\"inline-flex items-center gap-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("sync", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "<span>Rescan</span></span> <svg x-show=\"rescanning\" class=\"animate-spin inline-block h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\"><circle class=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" stroke-width=\"4\"></circle> <path class=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z\"></path></svg></button><div class=\"flex space-x-3\"><button @click=\"hideMetadataEditor()\" class=\"btn btn-secondary\">Cancel</button> <button @click=\"saveMetadata()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -1344,7 +1352,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 127, "Save</button></div></div></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 128, "Save</button></div></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+28
View File
@@ -97,6 +97,7 @@ interface MetadataEditorState {
coverFile: Blob | null;
coverAction: string;
saving: boolean;
rescanning: boolean;
userRating: number;
ratingHover: number;
ratingSaving: boolean;
@@ -110,6 +111,7 @@ interface MetadataEditorState {
generateCover(): Promise<void>;
removeCover(): void;
saveMetadata(): Promise<void>;
rescanBook(): Promise<void>;
starFill(i: number): string;
ratingText(): string;
setRating(value: number): Promise<void>;
@@ -149,6 +151,7 @@ Alpine.data("bookDetail", () => {
coverFile: null as Blob | null,
coverAction: "keep",
saving: false,
rescanning: false,
userRating: 0,
ratingHover: 0,
ratingSaving: false,
@@ -559,6 +562,31 @@ Alpine.data("bookDetail", () => {
}
},
async rescanBook() {
if (this.rescanning) return;
this.rescanning = true;
const mediaId = getMediaId();
try {
const resp = await fetch(`/api/media-items/${mediaId}/rescan`, {
method: "POST",
headers: { Authorization: getAuthHeader() },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to rescan book");
}
showToast("Book rescanned successfully", "success");
setTimeout(() => window.location.reload(), 500);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to rescan book",
"error",
);
} finally {
this.rescanning = false;
}
},
async searchEditorTags() {
const libraryId = document.body.getAttribute("data-library-id") || "";
if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) {