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 f699899408
commit 283b2f2ed7
6 changed files with 415 additions and 237 deletions
+59 -134
View File
@@ -3,14 +3,11 @@ package handlers
import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5"
)
@@ -19,12 +16,17 @@ type KOReaderHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
queue *wsync.SyncQueueProcessor
progressSvc *wsync.ProgressService
}
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
return &KOReaderHandler{db: db, connManager: connManager, queue: queue}
}
func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
h.progressSvc = svc
}
type KOReaderProgressRequest struct {
LibraryID *string `json:"library_id,omitempty"`
Books []KOReaderBookProgress `json:"books" validate:"required"`
@@ -192,7 +194,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
continue
}
err := h.updateProgressForBook(c, pgUserID, mediaItemID, book)
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
if err == nil {
booksSynced++
}
@@ -394,68 +396,46 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
return h.queue.EnqueueProgress(update)
}
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
ctx := c.Request().Context()
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
})
deviceInfo := book.DeviceInfo
deviceModel := deviceInfo.DeviceModel
if deviceModel == "" {
deviceModel = "KOReader Device"
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
if h.progressSvc != nil {
saveReq := wsync.SaveProgressRequest{
MediaItemID: mediaItemID,
UserID: userID,
Source: "koreader",
DeviceID: deviceID,
Percentage: &book.Percentage,
Epubcfi: book.Epubcfi,
Chapter: book.Chapter,
CharacterOffset: book.Character,
CurrentPage: book.Page,
TotalPages: book.TotalPages,
DeviceType: "koreader",
DeviceName: deviceModel,
Broadcast: true,
}
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
return err
}
hasExistingProgress := !errors.Is(err, pgx.ErrNoRows)
conflictDetected := false
if hasExistingProgress && existingProgress.LastSyncSource.Valid {
if existingProgress.LastSyncSource.String != "koreader" && existingProgress.LastSyncTimestamp.Valid {
timeDiff := time.Since(existingProgress.LastSyncTimestamp.Time)
if timeDiff < 5*time.Minute {
percentageDiff := book.Percentage - existingProgress.Percentage.Float64
if percentageDiff < 0 {
percentageDiff = -percentageDiff
}
if percentageDiff > 0.01 {
conflictDetected = true
}
}
}
}
var epubcfi pgtype.Text
var chapter pgtype.Int4
var characterOffset pgtype.Int8
var currentPage pgtype.Int4
var totalPages pgtype.Int4
if book.Epubcfi != nil {
epubcfi = pgtype.Text{String: *book.Epubcfi, Valid: true}
}
if book.Chapter != nil {
chapter = pgtype.Int4{Int32: int32(*book.Chapter), Valid: true}
}
if book.Character != nil {
characterOffset = pgtype.Int8{Int64: *book.Character, Valid: true}
}
if book.Page != nil {
currentPage = pgtype.Int4{Int32: int32(*book.Page), Valid: true}
}
if book.TotalPages != nil {
totalPages = pgtype.Int4{Int32: int32(*book.TotalPages), Valid: true}
}
_, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
_, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
MediaItemID: mediaItemID,
UserID: userID,
Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true},
Epubcfi: epubcfi,
Chapter: chapter,
Epubcfi: textPtrToPgText(book.Epubcfi),
Chapter: intPtrToPgInt4(book.Chapter),
ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true},
CharacterOffset: characterOffset,
CurrentPage: currentPage,
TotalPages: totalPages,
CharacterOffset: int64PtrToPgInt8(book.Character),
CurrentPage: intPtrToPgInt4(book.Page),
TotalPages: intPtrToPgInt4(book.TotalPages),
LastSyncDevice: pgtype.Text{String: "koreader", Valid: true},
LastSyncSource: pgtype.Text{String: "koreader", Valid: true},
ViewportY: pgtype.Float8{},
@@ -465,97 +445,42 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, userID pgtype.U
ReadingMode: pgtype.Text{},
ZoomLevel: pgtype.Float8{},
})
if err != nil {
return err
}
if conflictDetected {
koreaderData := map[string]interface{}{
"source": "koreader",
"timestamp": time.Now(),
"data": map[string]interface{}{
"percentage": book.Percentage,
},
}
if book.Epubcfi != nil {
koreaderData["data"].(map[string]interface{})["epubcfi"] = *book.Epubcfi
}
if book.Chapter != nil {
koreaderData["data"].(map[string]interface{})["chapter"] = *book.Chapter
}
if book.Character != nil {
koreaderData["data"].(map[string]interface{})["character"] = *book.Character
}
if book.Page != nil {
koreaderData["data"].(map[string]interface{})["page"] = *book.Page
}
if book.TotalPages != nil {
koreaderData["data"].(map[string]interface{})["total_pages"] = *book.TotalPages
}
existingData := map[string]interface{}{
"source": existingProgress.LastSyncSource.String,
"timestamp": existingProgress.LastSyncTimestamp.Time,
"data": map[string]interface{}{
"percentage": existingProgress.Percentage.Float64,
},
}
if existingProgress.Epubcfi.Valid {
existingData["data"].(map[string]interface{})["epubcfi"] = existingProgress.Epubcfi.String
}
if existingProgress.Chapter.Valid {
existingData["data"].(map[string]interface{})["chapter"] = existingProgress.Chapter.Int32
}
if existingProgress.CharacterOffset.Valid {
existingData["data"].(map[string]interface{})["character"] = existingProgress.CharacterOffset.Int64
}
if existingProgress.CurrentPage.Valid {
existingData["data"].(map[string]interface{})["page"] = existingProgress.CurrentPage.Int32
}
if existingProgress.TotalPages.Valid {
existingData["data"].(map[string]interface{})["total_pages"] = existingProgress.TotalPages.Int32
}
conflictData := map[string]interface{}{
"koreader": koreaderData,
"existing": existingData,
}
conflictDataJSON, _ := json.Marshal(conflictData)
_, err := h.db.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
MediaItemID: mediaItemID,
UserID: userID,
ConflictType: "progress",
ConflictData: conflictDataJSON,
})
if err == nil {
h.connManager.BroadcastConflictNotification(
mediaItemID.Bytes,
"detection",
"",
)
}
}
deviceInfo := book.DeviceInfo
if deviceInfo.DeviceModel == "" {
deviceInfo.DeviceModel = "KOReader Device"
}
h.connManager.BroadcastProgressUpdate(
mediaItemID.Bytes,
book.Percentage,
wsync.SourceDevice{
ID: uuid.UUID(userID.Bytes).String(),
Name: deviceInfo.DeviceModel,
ID: uuid.UUID(deviceID.Bytes).String(),
Name: deviceModel,
Type: "koreader",
},
)
_, err = h.db.UpdateDeviceLastSync(ctx, pgtype.UUID{Bytes: [16]byte{}, Valid: false})
return nil
}
return err
func textPtrToPgText(s *string) pgtype.Text {
if s != nil {
return pgtype.Text{String: *s, Valid: true}
}
return pgtype.Text{}
}
func intPtrToPgInt4(i *int) pgtype.Int4 {
if i != nil {
return pgtype.Int4{Int32: int32(*i), Valid: true}
}
return pgtype.Int4{}
}
func int64PtrToPgInt8(i *int64) pgtype.Int8 {
if i != nil {
return pgtype.Int8{Int64: *i, Valid: true}
}
return pgtype.Int8{}
}
func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {