Files
bookhoard/internal/handlers/progress.go
T
john-okeefe 4681fb474e feat: integrate ConnectionManager into Handler struct
Add WebSocket ConnectionManager to Handler:
- Add connManager field to Handler struct
- Update NewHandler to accept ConnectionManager parameter
- Update SetupRoutes to pass ConnectionManager through
- Import sync package with alias to avoid conflicts

This enables progress handlers to broadcast updates via WebSocket.
2026-01-30 21:47:56 -05:00

229 lines
7.1 KiB
Go

package handlers
import (
"bookmann/internal/database"
wsync "bookmann/internal/sync"
"context"
"net/http"
"strconv"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
// GetUniversalProgress retrieves progress with all location references
func (h *Handler) GetUniversalProgress(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
mediaItemID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID")
}
progress, err := h.db.GetUniversalProgress(context.Background(), database.GetUniversalProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
})
if err != nil {
if err == pgx.ErrNoRows {
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": mediaItemID,
"progress": nil,
})
}
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get progress")
}
response := map[string]interface{}{
"book_id": progress.MediaItemID.Bytes,
"format_group": progress.FormatGroup,
"universal_progress": map[string]interface{}{
"percentage": progress.Percentage.Float64,
},
"location_references": map[string]interface{}{
"percentage": progress.Percentage.Float64,
"page": int(progress.CurrentPage.Int32),
"total_pages": int(progress.TotalPages.Int32),
},
}
if progress.Epubcfi.Valid {
response["location_references"].(map[string]interface{})["epubcfi"] = progress.Epubcfi.String
}
if progress.Chapter.Valid {
response["location_references"].(map[string]interface{})["chapter"] = int(progress.Chapter.Int32)
}
if progress.ChapterProgress.Valid {
response["location_references"].(map[string]interface{})["chapter_progress"] = progress.ChapterProgress.Float64
}
if progress.CharacterOffset.Valid {
response["location_references"].(map[string]interface{})["character"] = int64(progress.CharacterOffset.Int64)
}
deviceSync := map[string]interface{}{}
if progress.LastSyncDevice.Valid {
deviceSync["device"] = progress.LastSyncDevice.String
}
if progress.LastSyncSource.Valid {
deviceSync["source"] = progress.LastSyncSource.String
}
if progress.LastSyncTimestamp.Valid {
deviceSync["last_sync"] = progress.LastSyncTimestamp.Time
}
if len(deviceSync) > 0 {
response["device_sync"] = deviceSync
}
return c.JSON(http.StatusOK, response)
}
// UpdateUniversalProgress updates progress with automatic conversion
func (h *Handler) UpdateUniversalProgress(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
mediaItemID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID")
}
var req struct {
Source string `json:"source"`
Location struct {
Percentage *float64 `json:"percentage"`
Page *int `json:"page"`
TotalPages *int `json:"total_pages"`
Epubcfi *string `json:"epubcfi"`
Character *int64 `json:"character"`
Chapter *int `json:"chapter"`
ViewportY *float64 `json:"viewport_y"`
} `json:"location"`
DeviceMetadata struct {
DeviceType string `json:"device_type"`
UserAgent string `json:"user_agent"`
} `json:"device_metadata"`
}
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
mediaItem, err := h.db.GetMediaItem(context.Background(), pgtype.UUID{Bytes: mediaItemID, Valid: true})
if err != nil {
return echo.NewHTTPError(http.StatusNotFound, "media item not found")
}
percentage := 0.0
if req.Location.Percentage != nil {
percentage = *req.Location.Percentage
} else if req.Location.Page != nil && req.Location.TotalPages != nil {
percentage = wsync.PageToPercentage(*req.Location.Page, *req.Location.TotalPages)
}
currentPage := 0
totalPages := 200
if req.Location.Page != nil {
currentPage = *req.Location.Page
}
if req.Location.TotalPages != nil {
totalPages = *req.Location.TotalPages
}
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
totalPages = int(mediaItem.PageCount.Int32)
}
var epubcfi pgtype.Text
if req.Location.Epubcfi != nil {
epubcfi = pgtype.Text{String: *req.Location.Epubcfi, Valid: true}
}
var chapter pgtype.Int4
if req.Location.Chapter != nil {
chapter = pgtype.Int4{Int32: int32(*req.Location.Chapter), Valid: true}
}
var viewportY pgtype.Float8
if req.Location.ViewportY != nil {
viewportY = pgtype.Float8{Float64: *req.Location.ViewportY, Valid: true}
}
var lastSyncDevice, lastSyncSource pgtype.Text
if req.DeviceMetadata.DeviceType != "" {
lastSyncDevice = pgtype.Text{String: req.DeviceMetadata.DeviceType, Valid: true}
lastSyncSource = pgtype.Text{String: req.Source, Valid: true}
}
updated, err := h.db.UpdateUniversalProgress(context.Background(), database.UpdateUniversalProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
ViewportY: viewportY,
LastSyncDevice: lastSyncDevice,
LastSyncSource: lastSyncSource,
CurrentPage: pgtype.Int4{Int32: int32(currentPage), Valid: true},
TotalPages: pgtype.Int4{Int32: int32(totalPages), Valid: true},
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to update progress")
}
// Broadcast progress update to all connected WebSocket clients
deviceName := "Web"
deviceType := "web"
if req.DeviceMetadata.DeviceType != "" {
deviceType = req.DeviceMetadata.DeviceType
deviceName = req.DeviceMetadata.DeviceType
}
h.connManager.BroadcastProgressUpdate(mediaItemID, percentage, wsync.SourceDevice{
ID: uuid.UUID(user.ID.Bytes).String(),
Name: deviceName,
Type: deviceType,
})
return c.JSON(http.StatusOK, map[string]interface{}{
"sync_status": "success",
"progress_updated": true,
"percentage": percentage,
"current_page": currentPage,
"total_pages": totalPages,
"last_read_at": updated.LastReadAt.Time,
})
}
// GetProgressHistory retrieves reading session history
func (h *Handler) GetProgressHistory(c echo.Context) error {
user := MustGetAuthenticatedUser(c)
mediaItemID, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID")
}
limit := 50
if l := c.QueryParam("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
history, err := h.db.GetReadingHistory(context.Background(), database.GetReadingHistoryParams{
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
Limit: int32(limit),
})
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get history")
}
return c.JSON(http.StatusOK, map[string]interface{}{
"media_item_id": mediaItemID,
"sessions": history,
"count": len(history),
})
}