diff --git a/cmd/server/main.go b/cmd/server/main.go index 2a7ac2a..2c8bd23 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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, diff --git a/internal/handlers/kobo.go b/internal/handlers/kobo.go index 5f22877..6c9479a 100644 --- a/internal/handlers/kobo.go +++ b/internal/handlers/kobo.go @@ -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,39 +399,38 @@ 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 - _, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Percentage: pgtype.Float8{Float64: percentage, Valid: true}, - LastSyncDevice: pgtype.Text{String: "kobo", Valid: true}, - LastSyncSource: pgtype.Text{String: "kobo", Valid: true}, - }) + 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, + Percentage: pgtype.Float8{Float64: percentage, Valid: true}, + 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, ")") } - _, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Epubcfi: pgtype.Text{String: epubcfi, Valid: true}, - 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}, - }) + 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: 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,34 +619,33 @@ 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 - _, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Percentage: pgtype.Float8{Float64: percentage, Valid: true}, - 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", - }, - ) + 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, + Percentage: pgtype.Float8{Float64: percentage, Valid: true}, + LastSyncDevice: pgtype.Text{String: "kobo", Valid: true}, + LastSyncSource: pgtype.Text{String: "kobo", Valid: true}, + }) } } @@ -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,24 +681,34 @@ 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 - _, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{ - MediaItemID: pgMediaUUID, - UserID: pgUserID, - Percentage: pgtype.Float8{Float64: percentage, Valid: true}, - LastSyncDevice: pgtype.Text{String: "kobo", Valid: true}, - LastSyncSource: pgtype.Text{String: "bookhoard", Valid: true}, - }) + 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, + Percentage: pgtype.Float8{Float64: percentage, Valid: true}, + LastSyncDevice: pgtype.Text{String: "kobo", Valid: true}, + LastSyncSource: pgtype.Text{String: "bookhoard", Valid: true}, + }) + } if err == nil { booksSynced++ diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index 1b53445..b6b8f47 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -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 { diff --git a/internal/handlers/media.go b/internal/handlers/media.go index f2cf439..aaf9e05 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -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()}) diff --git a/internal/handlers/progress_test.go b/internal/handlers/progress_test.go index d80f641..fa754a0 100644 --- a/internal/handlers/progress_test.go +++ b/internal/handlers/progress_test.go @@ -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 +} diff --git a/internal/sync/queue.go b/internal/sync/queue.go index df85652..e2253aa 100644 --- a/internal/sync/queue.go +++ b/internal/sync/queue.go @@ -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,