Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da1f689263 | ||
|
|
298330a3a8 | ||
|
|
0614795ca2 | ||
|
|
6aa958c78f |
+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
|
# This stage is ONLY used for running tests, never deployed to production
|
||||||
FROM golang:1.26-alpine AS test-runner
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1",
|
|||||||
# Final stage
|
# Final stage
|
||||||
FROM alpine:latest
|
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
|
# Install kepubify for EPUB→KEPUB conversion
|
||||||
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
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)
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -2078,56 +2079,94 @@ func (s *MediaScanner) extractPDFCover(pdfPath string) (string, error) {
|
|||||||
// Use pdfcpu API to extract images from first page
|
// Use pdfcpu API to extract images from first page
|
||||||
// ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error
|
// ExtractImagesFile(inFile, outDir string, selectedPages []string, conf *model.Configuration) error
|
||||||
err = pdfcpuapi.ExtractImagesFile(pdfPath, tmpDir, []string{"1"}, nil)
|
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 {
|
if err != nil {
|
||||||
// No images found or extraction failed - this is OK, just return empty
|
fmt.Printf("Warning: failed to create temp dir for PDF cover render %s: %v\n", pdfPath, err)
|
||||||
return "", nil
|
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
|
imageData, err := os.ReadFile(outPrefix + ".jpg")
|
||||||
entries, err := os.ReadDir(tmpDir)
|
if err != nil || len(imageData) < 1000 {
|
||||||
if err != nil || len(entries) == 0 {
|
fmt.Printf("Warning: PDF cover render produced no usable image for %s\n", pdfPath)
|
||||||
return "", nil
|
return ""
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
|
// Save cover to disk (same pattern as comics: {pdf_path}.cover.jpg)
|
||||||
coverPath := pdfPath + ".cover.jpg"
|
coverPath := pdfPath + ".cover.jpg"
|
||||||
if err := os.WriteFile(coverPath, imageData, 0644); err != nil {
|
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
|
// ComicInfo represents metadata from ComicInfo.xml
|
||||||
@@ -2618,6 +2657,51 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, mediaItemID pgtype.U
|
|||||||
return err
|
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) {
|
func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath string, libraryID pgtype.UUID) (database.MediaItems, error) {
|
||||||
return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
|
return s.db.GetMediaItemByFilePath(ctx, database.GetMediaItemByFilePathParams{
|
||||||
FilePath: s.getRelativePath(filePath),
|
FilePath: s.getRelativePath(filePath),
|
||||||
|
|||||||
@@ -609,7 +609,19 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
</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);">
|
<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
|
<button
|
||||||
@click="hideMetadataEditor()"
|
@click="hideMetadataEditor()"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
@@ -625,5 +637,6 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1336,7 +1336,15 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -1344,7 +1352,7 @@ func MetadataEditorModal(book handlers.MediaDetail) templ.Component {
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
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 {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ interface MetadataEditorState {
|
|||||||
coverFile: Blob | null;
|
coverFile: Blob | null;
|
||||||
coverAction: string;
|
coverAction: string;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
rescanning: boolean;
|
||||||
userRating: number;
|
userRating: number;
|
||||||
ratingHover: number;
|
ratingHover: number;
|
||||||
ratingSaving: boolean;
|
ratingSaving: boolean;
|
||||||
@@ -110,6 +111,7 @@ interface MetadataEditorState {
|
|||||||
generateCover(): Promise<void>;
|
generateCover(): Promise<void>;
|
||||||
removeCover(): void;
|
removeCover(): void;
|
||||||
saveMetadata(): Promise<void>;
|
saveMetadata(): Promise<void>;
|
||||||
|
rescanBook(): Promise<void>;
|
||||||
starFill(i: number): string;
|
starFill(i: number): string;
|
||||||
ratingText(): string;
|
ratingText(): string;
|
||||||
setRating(value: number): Promise<void>;
|
setRating(value: number): Promise<void>;
|
||||||
@@ -149,6 +151,7 @@ Alpine.data("bookDetail", () => {
|
|||||||
coverFile: null as Blob | null,
|
coverFile: null as Blob | null,
|
||||||
coverAction: "keep",
|
coverAction: "keep",
|
||||||
saving: false,
|
saving: false,
|
||||||
|
rescanning: false,
|
||||||
userRating: 0,
|
userRating: 0,
|
||||||
ratingHover: 0,
|
ratingHover: 0,
|
||||||
ratingSaving: false,
|
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() {
|
async searchEditorTags() {
|
||||||
const libraryId = document.body.getAttribute("data-library-id") || "";
|
const libraryId = document.body.getAttribute("data-library-id") || "";
|
||||||
if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) {
|
if (!this.tagSearch || this.tagSearch.length < 2 || !libraryId) {
|
||||||
|
|||||||
+38
-26
@@ -482,11 +482,14 @@ document.addEventListener("alpine:init", () => {
|
|||||||
tocItems: [] as any[],
|
tocItems: [] as any[],
|
||||||
mediaItemId: "" as string,
|
mediaItemId: "" as string,
|
||||||
saveTimeout: null as ReturnType<typeof setTimeout> | null,
|
saveTimeout: null as ReturnType<typeof setTimeout> | null,
|
||||||
// Set only by deliberate navigation (page turns, jumps, slider). The
|
// Position last known to be stored (the restore at open time, or the
|
||||||
// restore at open time and section-load relocations never set it, so
|
// last successful save). Relocations that don't move from it are never
|
||||||
// progress saves can only ever write a position the user actually
|
// written back, so a restored position can't clobber a newer device
|
||||||
// moved to — never a stale restore clobbering a newer device push.
|
// push — while swipe/scroll paging (handled inside foliate, with no
|
||||||
userMoved: false as boolean,
|
// wrapper method to flag) still saves normally.
|
||||||
|
lastSyncedCfi: "" as string,
|
||||||
|
lastSyncedFraction: -1 as number,
|
||||||
|
lastCfi: "" as string,
|
||||||
contextText: "" as string,
|
contextText: "" as string,
|
||||||
readingTheme: "light" as string,
|
readingTheme: "light" as string,
|
||||||
readingMode: "light" as string,
|
readingMode: "light" as string,
|
||||||
@@ -883,6 +886,7 @@ document.addEventListener("alpine:init", () => {
|
|||||||
const { fraction, location, pageItem, cfi, tocItem, section } =
|
const { fraction, location, pageItem, cfi, tocItem, section } =
|
||||||
e.detail;
|
e.detail;
|
||||||
this.hideSelectionPopover();
|
this.hideSelectionPopover();
|
||||||
|
this.lastCfi = cfi || "";
|
||||||
this.lastRelocateDetail = {
|
this.lastRelocateDetail = {
|
||||||
fraction,
|
fraction,
|
||||||
location,
|
location,
|
||||||
@@ -944,6 +948,11 @@ document.addEventListener("alpine:init", () => {
|
|||||||
} else {
|
} else {
|
||||||
await this.view.init({})
|
await this.view.init({})
|
||||||
}
|
}
|
||||||
|
// The position restored above (or the start of the book on a fresh
|
||||||
|
// open) is the baseline: only relocations that actually move from
|
||||||
|
// it may write progress.
|
||||||
|
this.lastSyncedCfi = this.lastCfi;
|
||||||
|
this.lastSyncedFraction = this.lastRelocateDetail?.fraction ?? -1;
|
||||||
// The renderer only knows it's a PDF once frames exist (they carry
|
// The renderer only knows it's a PDF once frames exist (they carry
|
||||||
// pdf.js onZoom), i.e. after init has rendered the first spread.
|
// pdf.js onZoom), i.e. after init has rendered the first spread.
|
||||||
// Read it now and apply the saved pointer mode — this also makes the
|
// Read it now and apply the saved pointer mode — this also makes the
|
||||||
@@ -953,10 +962,15 @@ document.addEventListener("alpine:init", () => {
|
|||||||
this.renderer.setAttribute("interaction-mode", this.interactionMode);
|
this.renderer.setAttribute("interaction-mode", this.interactionMode);
|
||||||
}
|
}
|
||||||
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
|
this.fxZoomed = this.isFixedLayout && this.renderer?.zoom != null;
|
||||||
// A bfcache-resurrected page is stale by definition: forbid it from
|
// A bfcache-resurrected page is stale by definition: re-baseline to
|
||||||
// writing its frozen position back until the user navigates again.
|
// its frozen position so it can't write that back until the user
|
||||||
|
// actually navigates again.
|
||||||
window.addEventListener("pageshow", (e: PageTransitionEvent) => {
|
window.addEventListener("pageshow", (e: PageTransitionEvent) => {
|
||||||
if (e.persisted) this.userMoved = false;
|
if (e.persisted) {
|
||||||
|
this.lastSyncedCfi = this.lastCfi;
|
||||||
|
this.lastSyncedFraction =
|
||||||
|
this.lastRelocateDetail?.fraction ?? -1;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.fetchReadingSpeed();
|
this.fetchReadingSpeed();
|
||||||
this.refreshAnnotations();
|
this.refreshAnnotations();
|
||||||
@@ -1557,9 +1571,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Fresh reading position from the database — the single source of
|
// Fresh reading position from the database — the single source of
|
||||||
// truth at open time. Fails soft to a fresh start: the userMoved gate
|
// truth at open time. Fails soft to a fresh start: change detection
|
||||||
// guarantees merely opening (even at the wrong spot) can never
|
// against the restored baseline guarantees merely opening (even at
|
||||||
// overwrite the stored position.
|
// the wrong spot) can never overwrite the stored position.
|
||||||
async fetchSavedLocation(): Promise<{
|
async fetchSavedLocation(): Promise<{
|
||||||
cfi?: string;
|
cfi?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
@@ -1591,9 +1605,17 @@ document.addEventListener("alpine:init", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
debouncedSaveProgress(fraction: number, location: any, cfi: string) {
|
||||||
// Only deliberate navigation writes progress: displaying a restored
|
// Only an actual change from the last stored position writes
|
||||||
// position must never overwrite a newer device push.
|
// progress: displaying a restored position must never overwrite a
|
||||||
if (!this.userMoved) return;
|
// newer device push. Swipes and scrolls are handled inside foliate
|
||||||
|
// with no wrapper method to flag, so position — not intent — is the
|
||||||
|
// signal. Books without CFIs (fixed layout, PDF) compare fraction.
|
||||||
|
const currentCfi = cfi || "";
|
||||||
|
const changed =
|
||||||
|
currentCfi || this.lastSyncedCfi
|
||||||
|
? currentCfi !== this.lastSyncedCfi
|
||||||
|
: Math.abs(fraction - this.lastSyncedFraction) > 1e-4;
|
||||||
|
if (!changed) return;
|
||||||
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
||||||
this.saveTimeout = setTimeout(() => {
|
this.saveTimeout = setTimeout(() => {
|
||||||
this.saveProgress(fraction, location, cfi);
|
this.saveProgress(fraction, location, cfi);
|
||||||
@@ -1644,6 +1666,8 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
this.lastSyncedCfi = cfi || "";
|
||||||
|
this.lastSyncedFraction = fraction;
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// silent fail — progress save is non-critical
|
// silent fail — progress save is non-critical
|
||||||
}
|
}
|
||||||
@@ -1734,23 +1758,18 @@ document.addEventListener("alpine:init", () => {
|
|||||||
saveSettings({ double_page_spread: this.doublePageSpread });
|
saveSettings({ double_page_spread: this.doublePageSpread });
|
||||||
},
|
},
|
||||||
goLeft() {
|
goLeft() {
|
||||||
this.userMoved = true;
|
|
||||||
this.view?.goLeft?.();
|
this.view?.goLeft?.();
|
||||||
},
|
},
|
||||||
goRight() {
|
goRight() {
|
||||||
this.userMoved = true;
|
|
||||||
this.view?.goRight?.();
|
this.view?.goRight?.();
|
||||||
},
|
},
|
||||||
nextPage() {
|
nextPage() {
|
||||||
this.userMoved = true;
|
|
||||||
this.view?.next?.();
|
this.view?.next?.();
|
||||||
},
|
},
|
||||||
previousPage() {
|
previousPage() {
|
||||||
this.userMoved = true;
|
|
||||||
this.view?.prev?.();
|
this.view?.prev?.();
|
||||||
},
|
},
|
||||||
goToFraction(value: string) {
|
goToFraction(value: string) {
|
||||||
this.userMoved = true;
|
|
||||||
this.view?.goToFraction?.(parseFloat(value));
|
this.view?.goToFraction?.(parseFloat(value));
|
||||||
},
|
},
|
||||||
toggleTOC() {
|
toggleTOC() {
|
||||||
@@ -1929,11 +1948,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
goToSearchResult(item: { cfi?: string; page?: number | null }) {
|
goToSearchResult(item: { cfi?: string; page?: number | null }) {
|
||||||
if (item.cfi) {
|
if (item.cfi) {
|
||||||
this.userMoved = true;
|
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
this.view?.goTo?.(item.cfi);
|
this.view?.goTo?.(item.cfi);
|
||||||
} else if (item.page != null) {
|
} else if (item.page != null) {
|
||||||
this.userMoved = true;
|
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
this.view?.goTo?.(item.page);
|
this.view?.goTo?.(item.page);
|
||||||
} else return;
|
} else return;
|
||||||
@@ -1962,7 +1979,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
goBackToLocation() {
|
goBackToLocation() {
|
||||||
const loc = this.backStack.pop();
|
const loc = this.backStack.pop();
|
||||||
if (!loc) return;
|
if (!loc) return;
|
||||||
this.userMoved = true;
|
|
||||||
if (loc.cfi) this.view?.goTo?.(loc.cfi);
|
if (loc.cfi) this.view?.goTo?.(loc.cfi);
|
||||||
else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
|
else if (typeof loc.page === "number") this.view?.goTo?.(loc.page);
|
||||||
},
|
},
|
||||||
@@ -1978,7 +1994,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
goToTOCItem(item: any) {
|
goToTOCItem(item: any) {
|
||||||
if (this.view && item.href) {
|
if (this.view && item.href) {
|
||||||
this.userMoved = true;
|
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
this.view.goTo(item.href);
|
this.view.goTo(item.href);
|
||||||
this.tocOpen = false;
|
this.tocOpen = false;
|
||||||
@@ -2106,7 +2121,6 @@ document.addEventListener("alpine:init", () => {
|
|||||||
},
|
},
|
||||||
goToPage(index: number) {
|
goToPage(index: number) {
|
||||||
if (!this.view || typeof index !== "number" || index < 0) return;
|
if (!this.view || typeof index !== "number" || index < 0) return;
|
||||||
this.userMoved = true;
|
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
this.view.goTo(index);
|
this.view.goTo(index);
|
||||||
this.tocOpen = false;
|
this.tocOpen = false;
|
||||||
@@ -2114,11 +2128,9 @@ document.addEventListener("alpine:init", () => {
|
|||||||
goToBookmark(item: { cfi: string; page: number | null }) {
|
goToBookmark(item: { cfi: string; page: number | null }) {
|
||||||
if (!this.view) return;
|
if (!this.view) return;
|
||||||
if (item.cfi) {
|
if (item.cfi) {
|
||||||
this.userMoved = true;
|
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
this.view.goTo(item.cfi);
|
this.view.goTo(item.cfi);
|
||||||
} else if (item.page != null && item.page > 0) {
|
} else if (item.page != null && item.page > 0) {
|
||||||
this.userMoved = true;
|
|
||||||
this.pushBackStack();
|
this.pushBackStack();
|
||||||
// Fixed-layout/comic: sections are pages; foliate takes an index.
|
// Fixed-layout/comic: sections are pages; foliate takes an index.
|
||||||
this.view.goTo(item.page - 1);
|
this.view.goTo(item.page - 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user