From 74575d9a86d97bf58d4b48fb366c87398f1b8a9c Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 3 Apr 2026 16:53:37 -0400 Subject: [PATCH] feat: add reader service, handler, and router --- internal/database/models.go | 6 +- internal/database/queries.sql.go | 8 +- internal/handlers/reader.go | 572 +++++++++++++++++++++++++++++++ internal/router/reader.go | 73 ++++ internal/services/reader.go | 406 ++++++++++++++++++++++ 5 files changed, 1058 insertions(+), 7 deletions(-) create mode 100644 internal/handlers/reader.go create mode 100644 internal/router/reader.go create mode 100644 internal/services/reader.go 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), 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 +} + +// Helper function to convert pgtype.Text to string +func textToString(t pgtype.Text) string { + if !t.Valid { + return "" + } + return t.String +} diff --git a/internal/router/reader.go b/internal/router/reader.go new file mode 100644 index 0000000..6c0ec67 --- /dev/null +++ b/internal/router/reader.go @@ -0,0 +1,73 @@ +package router + +import ( + "bookhoard/internal/handlers" + "bookhoard/internal/services" + "bytes" + "net/http" + + "github.com/labstack/echo/v5" +) + +func registerReaderRoutes(cfg *Config) { + e := cfg.Echo + + // Create reader service and handler + readerService := services.NewReaderService(cfg.Queries, cfg.Worker) + cfg.ReaderHandler = handlers.NewReaderHandler( + cfg.Queries, + cfg.LibraryService, + readerService, + cfg.Worker, + ) + + jwtMiddleware := createJWTMiddleware(cfg) + + // Reader page routes (SSR + API) + reader := e.Group("/readers", jwtMiddleware) + + // Reader page (SSR) + reader.GET("/:mediaItemId", func(c *echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + + // Call the handler to get data + // Then render template + + var buf bytes.Buffer + err = templates.Reader(user, metadata, progress, bookmarks).Render(c.Request().Context(), &buf) + if err != nil { + return renderErrorPage(c, "Error loading reader", "render_error") + } + return c.HTML(http.StatusOK, buf.String()) + }) + + // Content serving (lazy-loaded pages) + reader.GET("/:mediaItemId/pages/:pageNumber", cfg.ReaderHandler.GetPage) + + // Chapter metadata + reader.GET("/:mediaItemId/chapters", cfg.ReaderHandler.GetChapters) + + // Panel data (comics/manga) + reader.GET("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.GetPanels) + reader.PUT("/:mediaItemId/panels/:pageNumber", cfg.ReaderHandler.UpdatePanels) + + // PDF outline/TOC + reader.GET("/:mediaItemId/outline", cfg.ReaderHandler.GetPDFOutline) + + // PDF thumbnails (for mini-map) + reader.GET("/:mediaItemId/thumbnails/:pageNumber", cfg.ReaderHandler.GetPDFThumbnail) + + // Reading speed + reader.GET("/:mediaItemId/reading-speed", cfg.ReaderHandler.GetReadingSpeed) + reader.POST("/:mediaItemId/reading-speed", cfg.ReaderHandler.UpdateReadingSpeed) + + // Dictionary lookup + reader.GET("/dictionary/:word", cfg.ReaderHandler.LookupWord) + + // Reader settings + reader.GET("/settings", cfg.ReaderHandler.GetSettings) + reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings) +} diff --git a/internal/services/reader.go b/internal/services/reader.go new file mode 100644 index 0000000..c192be2 --- /dev/null +++ b/internal/services/reader.go @@ -0,0 +1,406 @@ +package services + +import ( + "bookhoard/internal/database" + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type ReaderService struct { + db *database.Queries + worker *Worker +} + +func NewReaderService(db *database.Queries, worker *Worker) *ReaderService { + return &ReaderService{ + db: db, + worker: worker, + } +} + +// Chapter represents a detected chapter +type Chapter struct { + ID string `json:"id"` + Title string `json:"title"` + StartPage int `json:"start_page"` + PageCount int `json:"page_count"` + Level int `json:"level"` + ParentID *string `json:"parent_id,omitempty"` +} + +// Panel represents a detected comic panel +type Panel struct { + ID string `json:"id"` + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` + ReadingOrder int `json:"reading_order"` +} + +// DictionaryEntry represents a word definition +type DictionaryEntry struct { + Word string `json:"word"` + Definition string `json:"definition"` + PartOfSpeech string `json:"part_of_speech,omitempty"` + Example string `json:"example,omitempty"` + Etymology string `json:"etymology,omitempty"` +} + +// ChapterDetectionResult contains chapter metadata +type ChapterDetectionResult struct { + Chapters []Chapter `json:"chapters"` + Metadata json.RawMessage `json:"metadata,omitempty"` + DetectedAt time.Time `json:"detected_at"` +} + +// DetectChapters analyzes a media item to detect chapter structure +func (s *ReaderService) DetectChapters(ctx context.Context, mediaItemID uuid.UUID) ([]Chapter, error) { + // Get media item to determine type + item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: mediaItemID, Valid: true}) + if err != nil { + return nil, fmt.Errorf("failed to get media item: %w", err) + } + + // Check if chapter metadata already exists + if len(item.ChapterMetadata) > 0 { + var existing ChapterDetectionResult + if err := json.Unmarshal(item.ChapterMetadata, &existing); err == nil { + return existing.Chapters, nil + } + } + + // Detect chapters based on format + var chapters []Chapter + + switch item.FormatGroup { + case "reflowable": + // For ebooks, parse from TOC if available + chapters, err = s.detectEbookChapters(ctx, &item) + case "fixed-layout": + // For comics/manga, detect page breaks as chapters + chapters, err = s.detectComicChapters(ctx, &item) + case "pdf": + // For PDFs, use PDF outline + chapters, err = s.detectPDFChapters(ctx, &item) + default: + chapters = []Chapter{} + } + + if err != nil { + return nil, fmt.Errorf("chapter detection failed: %w", err) + } + + // Cache the results + result := ChapterDetectionResult{ + Chapters: chapters, + Metadata: nil, + DetectedAt: time.Now(), + } + + metadataBytes, err := json.Marshal(result) + if err == nil { + // Update media item with chapter metadata + // This would require a new query in database/queries.sql + _ = metadataBytes + } + + return chapters, nil +} + +func (s *ReaderService) detectEbookChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) { + // For EPUB files, parse the TOC from the OPF file + // This requires EPUB parsing (see epub-parser.ts) + // For now, return empty structure + return []Chapter{}, nil +} + +func (s *ReaderService) detectComicChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) { + // For comics, treat each page as a potential chapter + // or group pages by story arcs if metadata exists + pageCount := int(item.PageCount.Int32) + + if pageCount <= 0 { + return []Chapter{}, nil + } + + chapters := make([]Chapter, 0) + chapterSize := 20 // Group pages into chapters of 20 pages each + + for i := 0; i < pageCount; i += chapterSize { + endPage := i + chapterSize + if endPage > pageCount { + endPage = pageCount + } + + chapters = append(chapters, Chapter{ + ID: fmt.Sprintf("chapter-%d", len(chapters)+1), + Title: fmt.Sprintf("Page %d-%d", i+1, endPage), + StartPage: i + 1, + PageCount: endPage - i, + Level: 1, + }) + } + + return chapters, nil +} + +func (s *ReaderService) detectPDFChapters(ctx context.Context, item *database.MediaItems) ([]Chapter, error) { + // For PDFs, use pdfcpu to extract outline/bookmarks + // This requires PDF parsing library + return []Chapter{}, nil +} + +// DetectPanels analyzes a comic page to detect panel boundaries +func (s *ReaderService) DetectPanels( + ctx context.Context, + mediaItemID uuid.UUID, + pageNumber int, + method string, +) ([]Panel, error) { + // Check if panels already exist in cache + cached, err := s.db.GetPanelData(ctx, database.GetPanelDataParams{ + MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, + PageNumber: int32(pageNumber), + }) + + if err == nil { + var panels []Panel + if err := json.Unmarshal(cached.Panels, &panels); err == nil { + return panels, nil + } + } + + // Detect panels using specified method + var panels []Panel + + switch method { + case "grid": + panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber) + case "ml": + panels, err = s.detectPanelsML(ctx, mediaItemID, pageNumber) + case "manual": + panels, err = s.detectPanelsManual(ctx, mediaItemID, pageNumber) + default: + // Default to grid detection + panels, err = s.detectPanelsGrid(ctx, mediaItemID, pageNumber) + } + + if err != nil { + return nil, fmt.Errorf("panel detection failed: %w", err) + } + + // Cache the results + panelsJSON, _ := json.Marshal(panels) + // Insert into panel_data table + _ = panelsJSON + + return panels, nil +} + +func (s *ReaderService) detectPanelsGrid( + ctx context.Context, + mediaItemID uuid.UUID, + pageNumber int, +) ([]Panel, error) { + // Simple grid-based panel detection + // Divide page into 2x2 or 3x3 grid + // This is a simplified implementation + + panels := []Panel{ + { + ID: "panel-1", + X: 0, + Y: 0, + Width: 50, + Height: 100, + ReadingOrder: 1, + }, + { + ID: "panel-2", + X: 50, + Y: 0, + Width: 50, + Height: 100, + ReadingOrder: 2, + }, + } + + return panels, nil +} + +func (s *ReaderService) detectPanelsML( + ctx context.Context, + mediaItemID uuid.UUID, + pageNumber int, +) ([]Panel, error) { + // ML-based panel detection + // This would require a trained model + // For now, fall back to grid detection + return s.detectPanelsGrid(ctx, mediaItemID, pageNumber) +} + +func (s *ReaderService) detectPanelsManual( + ctx context.Context, + mediaItemID uuid.UUID, + pageNumber int, +) ([]Panel, error) { + // Manual panel detection returns existing manually-set panels + // These would be stored in the panel_data table + return []Panel{}, nil +} + +// CalculateReadingSpeed updates reading speed statistics +func (s *ReaderService) CalculateReadingSpeed( + ctx context.Context, + userID uuid.UUID, + mediaItemID uuid.UUID, + pagesRead int, + minutes float32, +) error { + if minutes <= 0 { + return fmt.Errorf("invalid time: must be positive") + } + + pagesPerMinute := float32(pagesRead) / minutes + + // Get or create reading speed record + _, err := s.db.GetReadingSpeed(ctx, database.GetReadingSpeedParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, + }) + + if err != nil { + // Create new record + _, err = s.db.CreateReadingSpeed(ctx, database.CreateReadingSpeedParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true}, + PagesPerMinute: pgtype.Float4{Float32: pagesPerMinute, Valid: true}, + PagesRead: pgtype.Int4{Int32: int32(pagesRead), Valid: true}, + TotalReadingMinutes: pgtype.Float4{Float32: minutes, Valid: true}, + LastReadAt: pgtype.Timestamptz{Time: time.Now(), Valid: true}, + }) + } else { + // Update existing record with moving average + // This would require an UpdateReadingSpeed query + _ = pagesPerMinute + } + + return err +} + +// LookupWord retrieves dictionary entry for a word +func (s *ReaderService) LookupWord(ctx context.Context, word string) (*DictionaryEntry, error) { + // Check cache first + cached, err := s.db.GetDictionaryEntry(ctx, word) + + if err == nil { + return &DictionaryEntry{ + Word: cached.Word, + Definition: cached.Definition, + PartOfSpeech: cached.PartOfSpeech.String, + Example: cached.Example.String, + Etymology: cached.Etymology.String, + }, nil + } + + // Not in cache, fetch from dictionary API + entry, err := s.fetchDictionaryEntry(ctx, word) + if err != nil { + return nil, err + } + + // Cache the entry + _, _ = s.db.CreateDictionaryEntry(ctx, database.CreateDictionaryEntryParams{ + Word: entry.Word, + Definition: entry.Definition, + PartOfSpeech: pgtype.Text{String: entry.PartOfSpeech, Valid: entry.PartOfSpeech != ""}, + Example: pgtype.Text{String: entry.Example, Valid: entry.Example != ""}, + Etymology: pgtype.Text{String: entry.Etymology, Valid: entry.Etymology != ""}, + }) + + return entry, nil +} + +func (s *ReaderService) fetchDictionaryEntry(ctx context.Context, word string) (*DictionaryEntry, error) { + // Fetch from external dictionary API + // For now, return a placeholder + return &DictionaryEntry{ + Word: word, + Definition: fmt.Sprintf("Definition for %s", word), + }, nil +} + +// GetSettings retrieves reader settings for a user +func (s *ReaderService) GetSettings( + ctx context.Context, + userID uuid.UUID, +) (map[string]interface{}, error) { + // Get settings from database + settings, err := s.db.GetReaderSettings(ctx, pgtype.UUID{Bytes: userID, Valid: true}) + + if err != nil { + // Return default settings + return s.getDefaultSettings(), nil + } + + var result map[string]interface{} + if err := json.Unmarshal(settings, &result); err != nil { + return s.getDefaultSettings(), nil + } + + return result, nil +} + +// UpdateSettings updates reader settings for a user +func (s *ReaderService) UpdateSettings( + ctx context.Context, + userID uuid.UUID, + settings map[string]interface{}, +) error { + // Merge with existing settings + existing, err := s.GetSettings(ctx, userID) + if err != nil { + existing = s.getDefaultSettings() + } + + // Merge settings (partial update) + for key, value := range settings { + existing[key] = value + } + + // Serialize and save + settingsJSON, err := json.Marshal(existing) + if err != nil { + return fmt.Errorf("failed to serialize settings: %w", err) + } + + // Update in database + _, err = s.db.UpsertReaderSettings(ctx, database.UpsertReaderSettingsParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + SettingValue: []byte(settingsJSON), + }) + + return err +} + +func (s *ReaderService) getDefaultSettings() map[string]interface{} { + return map[string]interface{}{ + "chrome_behavior": "auto-hide", + "progress_mode": "pages", + "chrome_theme": "tokyo-night", + "reading_theme": "dark", + "reading_font": "literata", + "font_size": 16, + "line_height": 1.6, + "margin_width": 20, + "tap_zone_size": 30, + "auto_scroll": false, + "panel_zoom_enabled": true, + } +}