Files
bookhoard/internal/handlers/reader.go
T

573 lines
18 KiB
Go

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("<div class='ebook-page'><p>Page %d content</p></div>", 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("<div class='pdf-page'><p>Page %d content</p></div>", 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
}