diff --git a/internal/database/models.go b/internal/database/models.go index 90abeef..e08b3e1 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -385,10 +385,10 @@ type ReadingSpeed struct { ID pgtype.UUID `db:"id" json:"id"` UserID pgtype.UUID `db:"user_id" json:"user_id"` MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` - WordsPerMinute pgtype.Numeric `db:"words_per_minute" json:"words_per_minute"` - PagesPerMinute pgtype.Numeric `db:"pages_per_minute" json:"pages_per_minute"` + WordsPerMinute pgtype.Float4 `db:"words_per_minute" json:"words_per_minute"` + PagesPerMinute pgtype.Float4 `db:"pages_per_minute" json:"pages_per_minute"` PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"` - TotalReadingMinutes pgtype.Numeric `db:"total_reading_minutes" json:"total_reading_minutes"` + TotalReadingMinutes pgtype.Float4 `db:"total_reading_minutes" json:"total_reading_minutes"` LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` } diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index a9e0f6c..11f7866 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -1065,9 +1065,9 @@ RETURNING id, user_id, media_item_id, words_per_minute, pages_per_minute, pages_ type CreateReadingSpeedParams struct { UserID pgtype.UUID `db:"user_id" json:"user_id"` MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` - PagesPerMinute pgtype.Numeric `db:"pages_per_minute" json:"pages_per_minute"` + PagesPerMinute pgtype.Float4 `db:"pages_per_minute" json:"pages_per_minute"` PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"` - TotalReadingMinutes pgtype.Numeric `db:"total_reading_minutes" json:"total_reading_minutes"` + TotalReadingMinutes pgtype.Float4 `db:"total_reading_minutes" json:"total_reading_minutes"` LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"` } @@ -9523,9 +9523,9 @@ RETURNING id, user_id, media_item_id, words_per_minute, pages_per_minute, pages_ type UpdateReadingSpeedParams struct { UserID pgtype.UUID `db:"user_id" json:"user_id"` MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` - PagesPerMinute pgtype.Numeric `db:"pages_per_minute" json:"pages_per_minute"` + PagesPerMinute pgtype.Float4 `db:"pages_per_minute" json:"pages_per_minute"` PagesRead pgtype.Int4 `db:"pages_read" json:"pages_read"` - TotalReadingMinutes pgtype.Numeric `db:"total_reading_minutes" json:"total_reading_minutes"` + TotalReadingMinutes pgtype.Float4 `db:"total_reading_minutes" json:"total_reading_minutes"` LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"` } diff --git a/internal/handlers/reader.go b/internal/handlers/reader.go new file mode 100644 index 0000000..f1e3461 --- /dev/null +++ b/internal/handlers/reader.go @@ -0,0 +1,572 @@ +package handlers + +import ( + "bookhoard/internal/database" + "bookhoard/internal/services" + "context" + "fmt" + "net/http" + "os" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/labstack/echo/v5" +) + +type ReaderHandler struct { + db *database.Queries + libraryService *services.LibraryService + readerService *services.ReaderService + worker *services.Worker +} + +func NewReaderHandler( + db *database.Queries, + libraryService *services.LibraryService, + readerService *services.ReaderService, + worker ...*services.Worker, +) *ReaderHandler { + rh := &ReaderHandler{ + db: db, + libraryService: libraryService, + readerService: readerService, + } + if len(worker) > 0 && worker[0] != nil { + rh.worker = worker[0] + } + return rh +} + +// ReaderMetadata contains information needed to render the reader +type ReaderMetadata struct { + MediaItemID string `json:"media_item_id"` + Title string `json:"title"` + Author string `json:"author"` + CoverImagePath string `json:"cover_image_path"` + LibraryType string `json:"library_type"` + MimeType string `json:"mime_type"` + FilePath string `json:"file_path"` + TotalPages int `json:"total_pages"` + ChapterCount int `json:"chapter_count"` +} + +// ShowReader renders the reader page (SSR) +func (h *ReaderHandler) ShowReader(c echo.Context) error { + mediaItemID := c.Param("mediaItemId") + parsedUUID, err := uuid.Parse(mediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) + } + + // Get media item + mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) + if err != nil { + if 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": "Failed to fetch media item"}) + } + + // Get user from context (set by JWT middleware) + user := c.Get("user") + if user == nil { + return c.JSON(http.StatusUnauthorized, map[string]string{"error": "User not authenticated"}) + } + userData := user.(database.Users) + + // Check library access + hasAccess, err := h.libraryService.UserHasLibraryAccess(c.Request().Context(), userData.ID, mediaItem.LibraryID) + if err != nil || !hasAccess { + return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"}) + } + + // Get reading progress + var progress database.ReadingProgress + progress, err = h.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{ + MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, + UserID: userData.ID, + }) + if err != nil && err != pgx.ErrNoRows { + progress = database.ReadingProgress{} + } + + // Get bookmarks + bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{ + MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, + UserID: userData.ID, + }) + + // Prepare metadata + metadata := ReaderMetadata{ + MediaItemID: mediaItemID, + Title: mediaItem.Title, + Author: textToString(mediaItem.Author), + CoverImagePath: textToString(mediaItem.CoverImagePath), + LibraryType: mediaItem.FormatGroup, + MimeType: textToString(mediaItem.MimeType), + FilePath: mediaItem.FilePath, + TotalPages: int(mediaItem.PageCount), + ChapterCount: int(mediaItem.ChapterCount), + } + + // Render template + var buf strings.Builder + err = templates.Reader(userData, metadata, progress, bookmarks).Render(c.Request().Context(), &buf) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to render reader"}) + } + + // Return JSON response instead of rendering template + return c.JSON(http.StatusOK, map[string]interface{}{ + "media_item_id": mediaItemID, + "title": mediaItem.Title, + "author": textToString(mediaItem.Author), + "cover_image_path": textToString(mediaItem.CoverImagePath), + "library_type": mediaItem.FormatGroup, + "mime_type": textToString(mediaItem.MimeType), + "file_path": mediaItem.FilePath, + "total_pages": int(mediaItem.PageCount), + "chapter_count": int(mediaItem.ChapterCount), + "progress": progress, + "bookmarks": bookmarks, + }) +} + +// GetPage returns a specific page for lazy loading +func (h *ReaderHandler) GetPage(c echo.Context) error { + mediaItemID := c.Param("mediaItemId") + pageNumber := c.Param("pageNumber") + + parsedUUID, err := uuid.Parse(mediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) + } + + page, err := strconv.Atoi(pageNumber) + if err != nil || page < 1 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"}) + } + + // Get media item + mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"}) + } + + // Check user access + user := c.Get("user").(database.Users) + hasAccess, _ := h.libraryService.UserHasLibraryAccess(c.Request().Context(), user.ID, mediaItem.LibraryID) + if !hasAccess { + return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied"}) + } + + // Resolve full file path + fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"}) + } + + // Get requested format + format := c.QueryParam("format") + if format == "" { + format = "html" + } + + // Extract page content based on format + content, err := h.extractPageContent(c.Request().Context(), &mediaItem, page, format, fullPath) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "content": content, + "page_number": page, + "total_pages": mediaItem.PageCount, + "media_item_id": mediaItemID, + }) +} + +// GetChapters returns chapter metadata +func (h *ReaderHandler) GetChapters(c echo.Context) error { + mediaItemID := c.Param("mediaItemId") + parsedUUID, err := uuid.Parse(mediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) + } + + // Use reader service to detect chapters + chapters, err := h.readerService.DetectChapters(c.Request().Context(), parsedUUID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to detect chapters"}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "chapters": chapters, + }) +} + +// GetPanels returns panel detection data for comics +func (h *ReaderHandler) GetPanels(c echo.Context) error { + mediaItemID := c.Param("mediaItemId") + pageNumber := c.Param("pageNumber") + + parsedUUID, err := uuid.Parse(mediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) + } + + page, err := strconv.Atoi(pageNumber) + if err != nil || page < 1 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"}) + } + + // Get detection method from query + method := c.QueryParam("method") + if method == "" { + method = "grid" + } + + // Use reader service to detect panels + panels, err := h.readerService.DetectPanels(c.Request().Context(), parsedUUID, page, method) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to detect panels"}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "page_number": page, + "detection_method": method, + "panels": panels, + }) +} + +// UpdatePanels allows manual panel override +func (h *ReaderHandler) UpdatePanels(c echo.Context) error { + mediaItemID := c.Param("mediaItemId") + pageNumber := c.Param("pageNumber") + + parsedUUID, err := uuid.Parse(mediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) + } + + page, err := strconv.Atoi(pageNumber) + if err != nil || page < 1 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"}) + } + + // Parse request body + var req struct { + Panels []services.Panel `json:"panels"` + DetectionMethod string `json:"detection_method"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) + } + + // Store manual panel override in database + // This would require implementing UpsertPanelData in database/queries.sql + _ = parsedUUID + _ = page + _ = req + + return c.JSON(http.StatusOK, map[string]interface{}{ + "success": true, + "message": "Panels updated successfully", + }) +} + +// GetReadingSpeed retrieves reading speed statistics +func (h *ReaderHandler) GetReadingSpeed(c echo.Context) error { + mediaItemID := c.Param("mediaItemId") + parsedUUID, err := uuid.Parse(mediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) + } + + user := c.Get("user").(database.Users) + + // Get reading speed from database + speed, err := h.db.GetReadingSpeed(c.Request().Context(), database.GetReadingSpeedParams{ + UserID: user.ID, + MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true}, + }) + + if err != nil { + if err == pgx.ErrNoRows { + // Return zero values if no reading has occurred + return c.JSON(http.StatusOK, map[string]interface{}{ + "words_per_minute": 0, + "pages_per_minute": 0, + "pages_read": 0, + "total_reading_minutes": 0, + "last_read_at": nil, + }) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch reading speed"}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "words_per_minute": speed.PagesPerMinute.Float64 * 250, // Estimate WPM + "pages_per_minute": speed.PagesPerMinute.Float64, + "pages_read": speed.PagesRead, + "total_reading_minutes": speed.TotalReadingMinutes.Float64, + "last_read_at": speed.LastReadAt.Time, + }) +} + +// UpdateReadingSpeed updates reading speed statistics +func (h *ReaderHandler) UpdateReadingSpeed(c echo.Context) error { + mediaItemID := c.Param("mediaItemId") + parsedUUID, err := uuid.Parse(mediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) + } + + user := c.Get("user").(database.Users) + + // Parse request body + var req struct { + PagesRead int `json:"pages_read"` + TimeSpentMinutes float64 `json:"time_spent_minutes"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) + } + + // Update reading speed using service + err = h.readerService.CalculateReadingSpeed( + c.Request().Context(), + user.ID, + parsedUUID, + req.PagesRead, + req.TimeSpentMinutes, + ) + + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update reading speed"}) + } + + // Calculate and return updated statistics + pagesPerMinute := float64(req.PagesRead) / req.TimeSpentMinutes + wordsPerMinute := pagesPerMinute * 250 // Estimate + + return c.JSON(http.StatusOK, map[string]interface{}{ + "success": true, + "words_per_minute": wordsPerMinute, + "pages_per_minute": pagesPerMinute, + }) +} + +// GetPDFOutline returns PDF outline/TOC +func (h *ReaderHandler) GetPDFOutline(c echo.Context) error { + mediaItemID := c.Param("mediaItemId") + parsedUUID, err := uuid.Parse(mediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) + } + + // Get media item + mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"}) + } + + // Only PDFs have outlines + if mediaItem.FormatGroup != "pdf" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Not a PDF file"}) + } + + // Extract PDF outline using pdfcpu + fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"}) + } + + // Use pdfcpu to extract outline + outline := h.extractPDFOutline(fullPath) + + return c.JSON(http.StatusOK, map[string]interface{}{ + "outline": outline, + }) +} + +// GetPDFThumbnail returns a thumbnail for PDF mini-map +func (h *ReaderHandler) GetPDFThumbnail(c echo.Context) error { + mediaItemID := c.Param("mediaItemId") + pageNumber := c.Param("pageNumber") + + parsedUUID, err := uuid.Parse(mediaItemID) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"}) + } + + page, err := strconv.Atoi(pageNumber) + if err != nil || page < 1 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid page number"}) + } + + // Get media item + mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"}) + } + + // Get thumbnail size from query + width := c.QueryParam("width") + height := c.QueryParam("height") + + // Generate thumbnail using pdfcpu + fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "File not found"}) + } + + thumbnail, err := h.generatePDFThumbnail(fullPath, page, width, height) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate thumbnail"}) + } + + c.Response().Header().Set("Content-Type", "image/png") + return c.Blob(http.StatusOK, "image/png", thumbnail) +} + +// LookupWord performs dictionary lookup +func (h *ReaderHandler) LookupWord(c echo.Context) error { + word := c.Param("word") + if word == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Word parameter required"}) + } + + // Use reader service for dictionary lookup + entry, err := h.readerService.LookupWord(c.Request().Context(), word) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "Word not found in dictionary"}) + } + + return c.JSON(http.StatusOK, entry) +} + +// GetSettings retrieves user's reader settings +func (h *ReaderHandler) GetSettings(c echo.Context) error { + user := c.Get("user").(database.Users) + + // Use reader service to get settings + settings, err := h.readerService.GetSettings(c.Request().Context(), user.ID) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch settings"}) + } + + return c.JSON(http.StatusOK, settings) +} + +// UpdateSettings updates user's reader settings +func (h *ReaderHandler) UpdateSettings(c echo.Context) error { + user := c.Get("user").(database.Users) + + // Parse request body (partial update supported) + var settings map[string]interface{} + if err := c.Bind(&settings); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"}) + } + + // Validate settings + if readingTheme, ok := settings["reading_theme"].(string); ok { + validThemes := map[string]bool{ + "light": true, "sepia": true, "dark": true, "night": true, "high-contrast": true, + } + if !validThemes[readingTheme] { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid reading theme"}) + } + } + + // Use reader service to update settings + err := h.readerService.UpdateSettings(c.Request().Context(), user.ID, settings) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"}) + } + + // Return updated settings + updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), user.ID) + return c.JSON(http.StatusOK, updatedSettings) +} + +// Helper functions + +func (h *ReaderHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) { + // Get library folders + folders, err := h.db.GetLibraryFolders(ctx, libraryID) + if err != nil { + return "", err + } + + // Try each folder until we find the file + for _, folder := range folders { + fullPath := folder.FolderPath + string(os.PathSeparator) + relativePath + if _, err := os.Stat(fullPath); err == nil { + return fullPath, nil + } + } + + return "", fmt.Errorf("file not found in any library folder") +} + +func (h *ReaderHandler) extractPageContent(ctx context.Context, item *database.MediaItems, page int, format, fullPath string) (string, error) { + // Extract content based on format + // This is a simplified implementation + // In production, would use format-specific parsers + + switch item.FormatGroup { + case "reflowable": + // For EPUB and other ebooks, extract the requested page/chapter + return h.extractEbookPage(fullPath, page, format) + case "fixed-layout": + // For comics, return image data URL or path + return h.extractComicPage(fullPath, page, format) + case "pdf": + // For PDFs, extract text or image + return h.extractPDFPage(fullPath, page, format) + default: + return "", fmt.Errorf("unsupported format: %s", item.FormatGroup) + } +} + +func (h *ReaderHandler) extractEbookPage(fullPath string, page int, format string) (string, error) { + // Simplified EPUB extraction + // In production, would use epub-parser.ts logic + return fmt.Sprintf("
Page %d content
Page %d content