feat(handlers): integrate ProgressService into media, koreader, kobo, and queue

All four progress write paths now delegate to ProgressService.SaveProgress:

- MediaHandler: UpdateMediaReadingProgress uses ProgressService for web
  saves with richer request body (reading_mode, zoom_level, scroll). GET
  now uses GetUniversalProgress query that JOINs media_items for
  format_group, total_characters, chapter_count.

- KOReaderHandler: updateProgressForBook delegates to ProgressService.
  Fixed device ID bug (was using userID, now uses deviceID). Removed
  duplicate UpdateDeviceLastSync with zero UUID. Added pgtype helper
  functions (textPtrToPgText, intPtrToPgInt4, int64PtrToPgInt8).

- KoboHandler: all four progress write points (Markup ReadingSync, Markup
  last-read-place, AnalyticsGettests, SyncFromServer) delegate to
  ProgressService. Fixed empty epubcfi string now correctly set to
  Valid: false. SyncFromServer preserves last_sync_source=bookhoard
  and Broadcast: false.

- QueueProcessor: syncProgress delegates to ProgressService.

- main.go: creates ProgressService after ConnectionManager, injects via
  SetProgressService() on all handlers and queue processor.

Handler tests cover pgtype conversion helpers (textPtrToPgText, etc.)
and device icon mapping.
This commit is contained in:
2026-04-25 21:16:29 -04:00
parent d8330e8d0a
commit 1cda4e5191
6 changed files with 415 additions and 237 deletions
+96 -12
View File
@@ -3,6 +3,7 @@ package handlers
import (
"bookhoard/internal/database"
"bookhoard/internal/services"
wsync "bookhoard/internal/sync"
"bookhoard/internal/utils"
"context"
"encoding/json"
@@ -125,6 +126,7 @@ type MediaHandler struct {
worker *services.Worker
libraryService *services.LibraryService
searchService *services.SearchService
progressSvc *wsync.ProgressService
}
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
@@ -139,6 +141,10 @@ func NewMediaHandler(db *database.Queries, libraryService *services.LibraryServi
return mh
}
func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
mh.progressSvc = svc
}
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
bookUUID, err := uuid.Parse(c.Param("uuid"))
if err != nil {
@@ -890,7 +896,7 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
}
progress, err := mh.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
progress, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
})
@@ -904,7 +910,27 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, progress)
resp := map[string]interface{}{
"id": progress.ID,
"media_item_id": progress.MediaItemID,
"user_id": progress.UserID,
"current_page": progress.CurrentPage,
"total_pages": progress.TotalPages,
"last_read_at": progress.LastReadAt,
"percentage": progress.Percentage,
"character_offset": progress.CharacterOffset,
"epubcfi": progress.Epubcfi,
"chapter": progress.Chapter,
"chapter_progress": progress.ChapterProgress,
"format_group": progress.FormatGroup,
"total_characters": progress.TotalCharacters,
"chapter_count": progress.ChapterCount,
"last_sync_device": progress.LastSyncDevice,
"last_sync_source": progress.LastSyncSource,
"last_sync_timestamp": progress.LastSyncTimestamp,
}
return c.JSON(http.StatusOK, resp)
}
// UpdateMediaReadingProgress handles PUT /api/media-items/:id/progress
@@ -922,24 +948,82 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
}
var req struct {
CurrentPage int32 `json:"current_page"`
TotalPages int32 `json:"total_pages"`
Epubcfi string `json:"epubcfi"`
Percentage float64 `json:"percentage"`
CurrentPage *int32 `json:"current_page"`
TotalPages *int32 `json:"total_pages"`
Epubcfi *string `json:"epubcfi"`
Percentage *float64 `json:"percentage"`
Chapter *int `json:"chapter"`
ChapterProgress *float64 `json:"chapter_progress"`
CharacterOffset *int64 `json:"character_offset"`
ReadingMode *string `json:"reading_mode"`
ZoomLevel *float64 `json:"zoom_level"`
ScrollX *float64 `json:"scroll_position_x"`
ScrollY *float64 `json:"scroll_position_y"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
}
if err := c.Validate(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
if mh.progressSvc != nil {
saveReq := wsync.SaveProgressRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Source: "web",
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
Percentage: req.Percentage,
Epubcfi: req.Epubcfi,
CharacterOffset: req.CharacterOffset,
Chapter: req.Chapter,
ChapterProgress: req.ChapterProgress,
CurrentPage: nil,
TotalPages: nil,
ZoomLevel: req.ZoomLevel,
ScrollX: req.ScrollX,
ScrollY: req.ScrollY,
ReadingMode: req.ReadingMode,
DeviceType: "web",
DeviceName: "Web",
Broadcast: true,
}
if req.CurrentPage != nil {
cp := int(*req.CurrentPage)
saveReq.CurrentPage = &cp
}
if req.TotalPages != nil {
tp := int(*req.TotalPages)
saveReq.TotalPages = &tp
}
result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, result)
}
percentage := 0.0
if req.Percentage != nil {
percentage = *req.Percentage
}
epubcfi := ""
if req.Epubcfi != nil {
epubcfi = *req.Epubcfi
}
currentPage := int32(0)
if req.CurrentPage != nil {
currentPage = *req.CurrentPage
}
totalPages := int32(0)
if req.TotalPages != nil {
totalPages = *req.TotalPages
}
progress, err := mh.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Percentage: pgtype.Float8{Float64: req.Percentage, Valid: true},
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
CharacterOffset: pgtype.Int8{Valid: false},
Epubcfi: pgtype.Text{String: req.Epubcfi, Valid: req.Epubcfi != ""},
Epubcfi: pgtype.Text{String: epubcfi, Valid: epubcfi != ""},
Chapter: pgtype.Int4{Valid: false},
ChapterProgress: pgtype.Float8{Valid: false},
ViewportX: pgtype.Float8{Valid: false},
@@ -951,8 +1035,8 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
ReadingMode: pgtype.Text{Valid: false},
LastSyncDevice: pgtype.Text{String: "web", Valid: true},
LastSyncSource: pgtype.Text{String: "web", Valid: true},
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
CurrentPage: pgtype.Int4{Int32: currentPage, Valid: true},
TotalPages: pgtype.Int4{Int32: totalPages, Valid: totalPages > 0},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})