package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" "context" "errors" "fmt" "net/http" "os" "strconv" "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 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": "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) // Get user's visible libraries libraries, err := h.libraryService.GetUserVisibleLibraries(c.Request().Context(), userData.ID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to check library access"}) } // Check if media item's library is in visible list visible := false for _, lib := range libraries { if lib.ID == mediaItem.LibraryID { visible = true break } } if !visible { return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"}) } // Get library with type information for panel detection decision libraryWithType, err := h.db.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to get library info"}) } // Determine if panel detection should be enabled enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup) // 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 && !errors.Is(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, }) // 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": mediaItem.PageCount, "chapter_count": mediaItem.ChapterCount, "progress": progress, "bookmarks": bookmarks, "enable_panel_detection": enablePanelDetection, "format_group": mediaItem.FormatGroup, "manga_type": mediaItem.MangaType, "reading_direction": mediaItem.ReadingDirection, }) } // 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"}) } // 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) // Get user's visible libraries libraries, err := h.libraryService.GetUserVisibleLibraries(c.Request().Context(), userData.ID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to check library access"}) } // Check if media item's library is in visible list visible := false for _, lib := range libraries { if lib.ID == mediaItem.LibraryID { visible = true break } } if !visible { return c.JSON(http.StatusForbidden, map[string]string{"error": "Access denied to this library"}) } // 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 errors.Is(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.Float32 * 250, // Estimate WPM "pages_per_minute": speed.PagesPerMinute.Float32, "pages_read": speed.PagesRead, "total_reading_minutes": speed.TotalReadingMinutes.Float32, "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 float32 `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.Bytes, 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 := float32(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.Bytes) 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, "paper": true, "sepia": true, "parchment": true, "warm": true, "candlelight": true, "azure": true, "sky": true, "arctic": true, "frost": true, "dusk": true, "sunset": true, "twilight": true, "forest": true, "moss": true, "slate": true, "oled": true, "solarized": 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.Bytes, 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.Bytes) 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), nil } func (h *ReaderHandler) extractComicPage(fullPath string, page int, format string) (string, error) { // For comics, return image path or data URL return fmt.Sprintf("/api/readers/comic-image?page=%d", page), nil } func (h *ReaderHandler) extractPDFPage(fullPath string, page int, format string) (string, error) { // For PDFs, extract text content or image return fmt.Sprintf("

Page %d content

", page), nil } func (h *ReaderHandler) extractPDFOutline(fullPath string) []map[string]interface{} { // Extract PDF outline using pdfcpu // This is a placeholder return []map[string]interface{}{} } func (h *ReaderHandler) generatePDFThumbnail(fullPath string, page int, width, height string) ([]byte, error) { // Generate thumbnail using pdfcpu // This is a placeholder return []byte{}, nil } func (h *ReaderHandler) ParseEbook(c *echo.Context) error { mediaItemID := c.Param("mediaItemId") parsedUUID, err := uuid.Parse(mediaItemID) if err != nil { return c.JSON(400, map[string]string{"error": "Invalid media item ID"}) } // Fetch media item mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return c.JSON(404, map[string]string{"error": "Media item not found"}) } return c.JSON(500, map[string]string{"error": "Failed to fetch media item"}) } // Check if format requires server-side parsing requiresServerParsing := false formatName := "" switch mediaItem.MimeType.String { case "application/x-mobipocket-ebook": requiresServerParsing = true formatName = "MOBI" case "application/vnd.amazon.mobi8-ebook": requiresServerParsing = true formatName = "AZW3/KF8" case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": requiresServerParsing = true formatName = "DOCX" case "application/rtf": requiresServerParsing = true formatName = "RTF" } if requiresServerParsing { // Phase 1: Return 501 for server-side formats return c.JSON(501, map[string]interface{}{ "error": fmt.Sprintf("Server-side %s parsing is not yet implemented", formatName), "message": fmt.Sprintf("%s format support is planned for Phase 2.5", formatName), "suggestion": "Please convert your ebook to EPUB format for now", "format": formatName, "phase": "2.5", }) } // Client-side format - should not call this endpoint return c.JSON(400, map[string]string{ "error": "This format should be parsed client-side, not on the server", }) } // shouldEnablePanelDetection determines if panel detection should be enabled // based on library type and format group func shouldEnablePanelDetection(libraryType string, formatGroup string) bool { panelLibraries := map[string]bool{ "manga": true, "comics": true, } panelFormats := map[string]bool{ "fixed_layout": true, "comic_archive": true, } return panelLibraries[libraryType] && panelFormats[formatGroup] }