feat: add reader service, handler, and router
This commit is contained in:
@@ -385,10 +385,10 @@ type ReadingSpeed struct {
|
|||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
WordsPerMinute pgtype.Numeric `db:"words_per_minute" json:"words_per_minute"`
|
WordsPerMinute pgtype.Float4 `db:"words_per_minute" json:"words_per_minute"`
|
||||||
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"`
|
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"`
|
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1065,9 +1065,9 @@ RETURNING id, user_id, media_item_id, words_per_minute, pages_per_minute, pages_
|
|||||||
type CreateReadingSpeedParams struct {
|
type CreateReadingSpeedParams struct {
|
||||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_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"`
|
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"`
|
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 {
|
type UpdateReadingSpeedParams struct {
|
||||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_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"`
|
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"`
|
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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("<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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user