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
+6 -3
View File
@@ -62,8 +62,10 @@ func main() {
// Create WebSocket connection manager
connManager := sync.NewConnectionManager()
// Create sync queue processor
progressService := sync.NewProgressService(queries, connManager)
queueProcessor := sync.NewSyncQueueProcessor(queries)
queueProcessor.SetProgressService(progressService)
// Create library service
libraryService := services.NewLibraryService(queries)
@@ -73,21 +75,21 @@ func main() {
services.WorkerInstance = worker
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
koreaderHandler.SetProgressService(progressService)
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
conflictHandler := handlers.NewConflictHandler(queries, connManager)
analyticsHandler := handlers.NewAnalyticsHandler(queries)
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
// Create conversion service for EPUB→KEPUB conversion
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
// NEW: Create refactored handlers
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
dashboardService := services.NewDashboardService(queries)
dashboardHandler := handlers.NewDashboardHandler(queries)
filtersHandler := handlers.NewFiltersHandler(queries)
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
mediaHandler.SetProgressService(progressService)
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
jobsHandler := handlers.NewJobsHandler(queries, worker)
@@ -153,6 +155,7 @@ func main() {
SidecarHandler: sidecarHandler,
ConnManager: connManager,
QueueProcessor: queueProcessor,
ProgressService: progressService,
DeviceAuthMiddleware: deviceAuthMiddleware,
JobsHandler: jobsHandler,
LoginTracker: loginAttemptTracker,
+62 -54
View File
@@ -3,9 +3,6 @@ package handlers
import (
"bookhoard/internal/database"
wsync "bookhoard/internal/sync"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"regexp"
@@ -20,12 +17,17 @@ import (
type KoboHandler struct {
db *database.Queries
connManager *wsync.ConnectionManager
progressSvc *wsync.ProgressService
}
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
return &KoboHandler{db: db, connManager: connManager}
}
func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
h.progressSvc = svc
}
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
// Enhanced Kobo Sync - ContentId Mapping Logic
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
@@ -175,12 +177,6 @@ func looksLikeSHA256(s string) bool {
return matched
}
// calculateFileSHA256 calculates SHA-256 hash of file path
func calculateFileSHA256(filePath string) string {
hash := sha256.Sum256([]byte(filePath))
return hex.EncodeToString(hash[:])
}
type KoboDeviceInfo struct {
DeviceID string `json:"DeviceId"`
Model string `json:"Model"`
@@ -403,19 +399,27 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
unlinkedBooks := 0
for _, readingSync := range req.ReadingSync {
// Use ContentId mapping with fallback logic
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
if err != nil || bookhoardUUID == uuid.Nil {
// Unlinked book detected
unlinkedBooks++
// TODO: Create unlinked book entry for manual resolution
continue
}
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
percentage := readingSync.PercentRead / 100.0
if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: true,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
@@ -423,19 +427,10 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
})
}
if err == nil {
markupsSynced++
h.connManager.BroadcastProgressUpdate(
bookhoardUUID,
percentage,
wsync.SourceDevice{
ID: uuid.UUID(userID).String(),
Name: device.DeviceName,
Type: "kobo",
},
)
}
}
@@ -481,15 +476,33 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
epubcfi = strings.TrimSuffix(epubcfi, ")")
}
chapter := bookmarkSync.Chapter
chapterProgress := 0.5
if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Epubcfi: &epubcfi,
Chapter: &chapter,
ChapterProgress: &chapterProgress,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: false,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Epubcfi: pgtype.Text{String: epubcfi, Valid: true},
Epubcfi: pgtype.Text{String: epubcfi, Valid: epubcfi != ""},
Chapter: pgtype.Int4{Int32: int32(bookmarkSync.Chapter), Valid: true},
ChapterProgress: pgtype.Float8{Float64: 0.5, Valid: true},
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
})
}
if err != nil {
fmt.Printf("Failed to store last-read-place: %v", err)
}
@@ -606,16 +619,26 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
}
for _, test := range req {
// Use ContentId mapping with fallback logic
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID)
if err != nil || bookhoardUUID == uuid.Nil {
// Unlinked book - skip
continue
}
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
percentage := test.PercentRead / 100.0
if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "kobo",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: true,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
@@ -623,17 +646,6 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
})
if err == nil {
h.connManager.BroadcastProgressUpdate(
bookhoardUUID,
percentage,
wsync.SourceDevice{
ID: uuid.UUID(userID).String(),
Name: device.DeviceName,
Type: "kobo",
},
)
}
}
@@ -649,20 +661,6 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
})
}
func parseKoboDeviceHeader(c *echo.Context) (KoboDeviceInfo, error) {
deviceHeader := c.Request().Header.Get("x-kobo-device")
if deviceHeader == "" {
return KoboDeviceInfo{}, fmt.Errorf("missing x-kobo-device header")
}
var device KoboDeviceInfo
if err := json.Unmarshal([]byte(deviceHeader), &device); err != nil {
return KoboDeviceInfo{}, fmt.Errorf("invalid device header format")
}
return device, nil
}
func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
device := c.Get("device").(database.Devices)
userID := device.UserID.Bytes
@@ -683,17 +681,26 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
highlightsSent := 0
for _, syncData := range req {
// Use ContentId mapping with fallback logic
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID)
if err != nil || bookhoardUUID == uuid.Nil {
// Unlinked book - skip
continue
}
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
percentage := syncData.PercentRead / 100.0
if h.progressSvc != nil {
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
Source: "bookhoard",
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
Percentage: &percentage,
DeviceType: "kobo",
DeviceName: device.DeviceName,
Broadcast: false,
})
} else {
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
MediaItemID: pgMediaUUID,
UserID: pgUserID,
@@ -701,6 +708,7 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
LastSyncSource: pgtype.Text{String: "bookhoard", Valid: true},
})
}
if err == nil {
booksSynced++
+57 -132
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{
deviceInfo := book.DeviceInfo
deviceModel := deviceInfo.DeviceModel
if deviceModel == "" {
deviceModel = "KOReader Device"
}
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,
}
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
_, 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 {
+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()})
+122
View File
@@ -3,6 +3,7 @@ package handlers
import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/stretchr/testify/assert"
)
@@ -28,3 +29,124 @@ func TestGetDeviceIcon_Unknown(t *testing.T) {
result = getDeviceIcon("")
assert.Equal(t, "📚", result)
}
func TestTextPtrToPgText(t *testing.T) {
t.Run("nil returns invalid", func(t *testing.T) {
result := textPtrToPgText(nil)
assert.False(t, result.Valid)
})
t.Run("non-nil returns valid", func(t *testing.T) {
s := "epubcfi(/6/4/2:10)"
result := textPtrToPgText(&s)
assert.True(t, result.Valid)
assert.Equal(t, s, result.String)
})
t.Run("empty string returns valid", func(t *testing.T) {
s := ""
result := textPtrToPgText(&s)
assert.True(t, result.Valid)
assert.Equal(t, "", result.String)
})
}
func TestIntPtrToPgInt4(t *testing.T) {
t.Run("nil returns invalid", func(t *testing.T) {
result := intPtrToPgInt4(nil)
assert.False(t, result.Valid)
})
t.Run("non-nil returns valid", func(t *testing.T) {
v := 5
result := intPtrToPgInt4(&v)
assert.True(t, result.Valid)
assert.Equal(t, int32(5), result.Int32)
})
}
func TestInt64PtrToPgInt8(t *testing.T) {
t.Run("nil returns invalid", func(t *testing.T) {
result := int64PtrToPgInt8(nil)
assert.False(t, result.Valid)
})
t.Run("non-nil returns valid", func(t *testing.T) {
v := int64(10000)
result := int64PtrToPgInt8(&v)
assert.True(t, result.Valid)
assert.Equal(t, int64(10000), result.Int64)
})
}
func TestFloat64PtrHelpers(t *testing.T) {
t.Run("pgtype float64 valid", func(t *testing.T) {
v := pgtype.Float8{Float64: 0.5, Valid: true}
result := float64PtrVal(v)
assert.NotNil(t, result)
assert.InDelta(t, 0.5, *result, 0.001)
})
t.Run("pgtype float64 invalid", func(t *testing.T) {
v := pgtype.Float8{Valid: false}
result := float64PtrVal(v)
assert.Nil(t, result)
})
t.Run("pgtype text valid", func(t *testing.T) {
v := pgtype.Text{String: "hello", Valid: true}
result := textPtrVal(v)
assert.NotNil(t, result)
assert.Equal(t, "hello", *result)
})
t.Run("pgtype text invalid", func(t *testing.T) {
v := pgtype.Text{Valid: false}
result := textPtrVal(v)
assert.Nil(t, result)
})
t.Run("pgtype int4 valid", func(t *testing.T) {
v := pgtype.Int4{Int32: 42, Valid: true}
result := int32PtrVal(v)
assert.NotNil(t, result)
assert.Equal(t, 42, *result)
})
t.Run("pgtype int4 invalid", func(t *testing.T) {
v := pgtype.Int4{Valid: false}
result := int32PtrVal(v)
assert.Nil(t, result)
})
t.Run("pgtype int8 valid", func(t *testing.T) {
v := pgtype.Int8{Int64: 10000, Valid: true}
result := int64PtrVal(v)
assert.NotNil(t, result)
assert.Equal(t, int64(10000), *result)
})
t.Run("pgtype int8 invalid", func(t *testing.T) {
v := pgtype.Int8{Valid: false}
result := int64PtrVal(v)
assert.Nil(t, result)
})
}
func float64PtrVal(v pgtype.Float8) *float64 {
if v.Valid {
return &v.Float64
}
return nil
}
func textPtrVal(v pgtype.Text) *string {
if v.Valid {
return &v.String
}
return nil
}
func int32PtrVal(v pgtype.Int4) *int {
if v.Valid {
val := int(v.Int32)
return &val
}
return nil
}
func int64PtrVal(v pgtype.Int8) *int64 {
if v.Valid {
return &v.Int64
}
return nil
}
+41 -5
View File
@@ -36,6 +36,7 @@ const (
type SyncQueueProcessor struct {
db *database.Queries
progressSvc *ProgressService
progressChan chan *ProgressUpdate
interval time.Duration
batchSize int
@@ -79,6 +80,10 @@ func NewSyncQueueProcessor(db *database.Queries) *SyncQueueProcessor {
}
}
func (p *SyncQueueProcessor) SetProgressService(svc *ProgressService) {
p.progressSvc = svc
}
func (p *SyncQueueProcessor) Start(ctx context.Context) {
log.Printf("Starting sync queue processor (interval: %v, batch: %d)", p.interval, p.batchSize)
@@ -310,6 +315,42 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI
return fmt.Errorf("missing percentage in sync data")
}
source := "queue"
if v, ok := syncData["source"].(string); ok {
source = v
}
if p.progressSvc != nil {
req := SaveProgressRequest{
MediaItemID: mediaItemID,
UserID: userID,
Source: source,
Percentage: &percentage,
Broadcast: false,
}
if v, ok := syncData["epubcfi"].(string); ok {
req.Epubcfi = &v
}
if v, ok := syncData["chapter"].(float64); ok {
ch := int(v)
req.Chapter = &ch
}
if v, ok := syncData["character"].(float64); ok {
co := int64(v)
req.CharacterOffset = &co
}
if v, ok := syncData["page"].(float64); ok {
pg := int(v)
req.CurrentPage = &pg
}
if v, ok := syncData["total_pages"].(float64); ok {
tp := int(v)
req.TotalPages = &tp
}
_, err := p.progressSvc.SaveProgress(ctx, req)
return err
}
var epubcfi pgtype.Text
if v, ok := syncData["epubcfi"].(string); ok {
epubcfi = pgtype.Text{String: v, Valid: true}
@@ -335,11 +376,6 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI
totalPages = pgtype.Int4{Int32: int32(v), Valid: true}
}
source := "queue"
if v, ok := syncData["source"].(string); ok {
source = v
}
_, err := p.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
MediaItemID: mediaItemID,
UserID: userID,