Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da1f689263 | ||
|
|
298330a3a8 | ||
|
|
0614795ca2 |
+2
-2
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -2078,56 +2079,94 @@ 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 {
|
||||
// Check for extracted images in the temp directory
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
if err == nil && len(entries) > 0 {
|
||||
// Find the largest image (likely the cover)
|
||||
var largestImage string
|
||||
var largestSize int64
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Skip very small files (likely thumbnails or icons)
|
||||
if info.Size() < 1000 {
|
||||
continue
|
||||
}
|
||||
if info.Size() > largestSize {
|
||||
largestImage = filepath.Join(tmpDir, entry.Name())
|
||||
largestSize = info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
if largestImage != "" {
|
||||
// Read the image
|
||||
imageData, err := os.ReadFile(largestImage)
|
||||
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 {
|
||||
return "", fmt.Errorf("failed to write cover file: %v", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
// No images found or extraction failed - this is OK, just return empty
|
||||
return "", 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 ""
|
||||
}
|
||||
|
||||
// Check for extracted images in the temp directory
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
if err != nil || len(entries) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Find the largest image (likely the cover)
|
||||
var largestImage string
|
||||
var largestSize int64
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Skip very small files (likely thumbnails or icons)
|
||||
if info.Size() < 1000 {
|
||||
continue
|
||||
}
|
||||
if info.Size() > largestSize {
|
||||
largestImage = filepath.Join(tmpDir, entry.Name())
|
||||
largestSize = info.Size()
|
||||
}
|
||||
}
|
||||
|
||||
if largestImage == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Read the image
|
||||
imageData, err := os.ReadFile(largestImage)
|
||||
if err != nil || len(imageData) == 0 {
|
||||
return "", nil
|
||||
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 {
|
||||
return "", fmt.Errorf("failed to write cover file: %v", err)
|
||||
fmt.Printf("Warning: failed to write rendered PDF cover for %s: %v\n", pdfPath, err)
|
||||
return ""
|
||||
}
|
||||
|
||||
return coverPath, nil
|
||||
return coverPath
|
||||
}
|
||||
|
||||
// ComicInfo represents metadata from ComicInfo.xml
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
@@ -625,5 +637,6 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user