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:
+6
-3
@@ -62,8 +62,10 @@ func main() {
|
|||||||
// Create WebSocket connection manager
|
// Create WebSocket connection manager
|
||||||
connManager := sync.NewConnectionManager()
|
connManager := sync.NewConnectionManager()
|
||||||
|
|
||||||
// Create sync queue processor
|
progressService := sync.NewProgressService(queries, connManager)
|
||||||
|
|
||||||
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
||||||
|
queueProcessor.SetProgressService(progressService)
|
||||||
|
|
||||||
// Create library service
|
// Create library service
|
||||||
libraryService := services.NewLibraryService(queries)
|
libraryService := services.NewLibraryService(queries)
|
||||||
@@ -73,21 +75,21 @@ func main() {
|
|||||||
services.WorkerInstance = worker
|
services.WorkerInstance = worker
|
||||||
|
|
||||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||||
|
koreaderHandler.SetProgressService(progressService)
|
||||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||||
|
|
||||||
// Create conversion service for EPUB→KEPUB conversion
|
|
||||||
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
||||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||||
|
|
||||||
// NEW: Create refactored handlers
|
|
||||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
||||||
dashboardService := services.NewDashboardService(queries)
|
dashboardService := services.NewDashboardService(queries)
|
||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||||
|
mediaHandler.SetProgressService(progressService)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ func main() {
|
|||||||
SidecarHandler: sidecarHandler,
|
SidecarHandler: sidecarHandler,
|
||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
QueueProcessor: queueProcessor,
|
QueueProcessor: queueProcessor,
|
||||||
|
ProgressService: progressService,
|
||||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||||
JobsHandler: jobsHandler,
|
JobsHandler: jobsHandler,
|
||||||
LoginTracker: loginAttemptTracker,
|
LoginTracker: loginAttemptTracker,
|
||||||
|
|||||||
+91
-83
@@ -3,9 +3,6 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -20,12 +17,17 @@ import (
|
|||||||
type KoboHandler struct {
|
type KoboHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
connManager *wsync.ConnectionManager
|
connManager *wsync.ConnectionManager
|
||||||
|
progressSvc *wsync.ProgressService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
||||||
return &KoboHandler{db: db, connManager: connManager}
|
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
|
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
|
||||||
// Enhanced Kobo Sync - ContentId Mapping Logic
|
// Enhanced Kobo Sync - ContentId Mapping Logic
|
||||||
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
|
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
|
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 {
|
type KoboDeviceInfo struct {
|
||||||
DeviceID string `json:"DeviceId"`
|
DeviceID string `json:"DeviceId"`
|
||||||
Model string `json:"Model"`
|
Model string `json:"Model"`
|
||||||
@@ -403,39 +399,38 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
unlinkedBooks := 0
|
unlinkedBooks := 0
|
||||||
|
|
||||||
for _, readingSync := range req.ReadingSync {
|
for _, readingSync := range req.ReadingSync {
|
||||||
// Use ContentId mapping with fallback logic
|
|
||||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
||||||
if err != nil || bookhoardUUID == uuid.Nil {
|
if err != nil || bookhoardUUID == uuid.Nil {
|
||||||
// Unlinked book detected
|
|
||||||
unlinkedBooks++
|
unlinkedBooks++
|
||||||
// TODO: Create unlinked book entry for manual resolution
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||||
|
|
||||||
percentage := readingSync.PercentRead / 100.0
|
percentage := readingSync.PercentRead / 100.0
|
||||||
|
|
||||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
if h.progressSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||||
UserID: pgUserID,
|
MediaItemID: pgMediaUUID,
|
||||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
UserID: pgUserID,
|
||||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
Source: "kobo",
|
||||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
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 {
|
if err == nil {
|
||||||
markupsSynced++
|
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, ")")
|
epubcfi = strings.TrimSuffix(epubcfi, ")")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
chapter := bookmarkSync.Chapter
|
||||||
MediaItemID: pgMediaUUID,
|
chapterProgress := 0.5
|
||||||
UserID: pgUserID,
|
|
||||||
Epubcfi: pgtype.Text{String: epubcfi, Valid: true},
|
if h.progressSvc != nil {
|
||||||
Chapter: pgtype.Int4{Int32: int32(bookmarkSync.Chapter), Valid: true},
|
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||||
ChapterProgress: pgtype.Float8{Float64: 0.5, Valid: true},
|
MediaItemID: pgMediaUUID,
|
||||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
UserID: pgUserID,
|
||||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
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 {
|
if err != nil {
|
||||||
fmt.Printf("Failed to store last-read-place: %v", err)
|
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 {
|
for _, test := range req {
|
||||||
// Use ContentId mapping with fallback logic
|
|
||||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID)
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID)
|
||||||
if err != nil || bookhoardUUID == uuid.Nil {
|
if err != nil || bookhoardUUID == uuid.Nil {
|
||||||
// Unlinked book - skip
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||||
percentage := test.PercentRead / 100.0
|
percentage := test.PercentRead / 100.0
|
||||||
|
|
||||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
if h.progressSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||||
UserID: pgUserID,
|
MediaItemID: pgMediaUUID,
|
||||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
UserID: pgUserID,
|
||||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
Source: "kobo",
|
||||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||||
})
|
Percentage: &percentage,
|
||||||
|
DeviceType: "kobo",
|
||||||
if err == nil {
|
DeviceName: device.DeviceName,
|
||||||
h.connManager.BroadcastProgressUpdate(
|
Broadcast: true,
|
||||||
bookhoardUUID,
|
})
|
||||||
percentage,
|
} else {
|
||||||
wsync.SourceDevice{
|
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||||
ID: uuid.UUID(userID).String(),
|
MediaItemID: pgMediaUUID,
|
||||||
Name: device.DeviceName,
|
UserID: pgUserID,
|
||||||
Type: "kobo",
|
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 {
|
func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||||
device := c.Get("device").(database.Devices)
|
device := c.Get("device").(database.Devices)
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
@@ -683,24 +681,34 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
|||||||
highlightsSent := 0
|
highlightsSent := 0
|
||||||
|
|
||||||
for _, syncData := range req {
|
for _, syncData := range req {
|
||||||
// Use ContentId mapping with fallback logic
|
|
||||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID)
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID)
|
||||||
if err != nil || bookhoardUUID == uuid.Nil {
|
if err != nil || bookhoardUUID == uuid.Nil {
|
||||||
// Unlinked book - skip
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||||
|
|
||||||
percentage := syncData.PercentRead / 100.0
|
percentage := syncData.PercentRead / 100.0
|
||||||
|
|
||||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
if h.progressSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||||
UserID: pgUserID,
|
MediaItemID: pgMediaUUID,
|
||||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
UserID: pgUserID,
|
||||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
Source: "bookhoard",
|
||||||
LastSyncSource: pgtype.Text{String: "bookhoard", Valid: true},
|
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 {
|
if err == nil {
|
||||||
booksSynced++
|
booksSynced++
|
||||||
|
|||||||
+59
-134
@@ -3,14 +3,11 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
@@ -19,12 +16,17 @@ type KOReaderHandler struct {
|
|||||||
db *database.Queries
|
db *database.Queries
|
||||||
connManager *wsync.ConnectionManager
|
connManager *wsync.ConnectionManager
|
||||||
queue *wsync.SyncQueueProcessor
|
queue *wsync.SyncQueueProcessor
|
||||||
|
progressSvc *wsync.ProgressService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
|
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
|
||||||
return &KOReaderHandler{db: db, connManager: connManager, queue: queue}
|
return &KOReaderHandler{db: db, connManager: connManager, queue: queue}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||||
|
h.progressSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
type KOReaderProgressRequest struct {
|
type KOReaderProgressRequest struct {
|
||||||
LibraryID *string `json:"library_id,omitempty"`
|
LibraryID *string `json:"library_id,omitempty"`
|
||||||
Books []KOReaderBookProgress `json:"books" validate:"required"`
|
Books []KOReaderBookProgress `json:"books" validate:"required"`
|
||||||
@@ -192,7 +194,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.updateProgressForBook(c, pgUserID, mediaItemID, book)
|
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
booksSynced++
|
booksSynced++
|
||||||
}
|
}
|
||||||
@@ -394,68 +396,46 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
|
|||||||
return h.queue.EnqueueProgress(update)
|
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()
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
deviceInfo := book.DeviceInfo
|
||||||
MediaItemID: mediaItemID,
|
deviceModel := deviceInfo.DeviceModel
|
||||||
UserID: userID,
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
hasExistingProgress := !errors.Is(err, pgx.ErrNoRows)
|
_, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
||||||
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{
|
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
||||||
Epubcfi: epubcfi,
|
Epubcfi: textPtrToPgText(book.Epubcfi),
|
||||||
Chapter: chapter,
|
Chapter: intPtrToPgInt4(book.Chapter),
|
||||||
ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
||||||
CharacterOffset: characterOffset,
|
CharacterOffset: int64PtrToPgInt8(book.Character),
|
||||||
CurrentPage: currentPage,
|
CurrentPage: intPtrToPgInt4(book.Page),
|
||||||
TotalPages: totalPages,
|
TotalPages: intPtrToPgInt4(book.TotalPages),
|
||||||
LastSyncDevice: pgtype.Text{String: "koreader", Valid: true},
|
LastSyncDevice: pgtype.Text{String: "koreader", Valid: true},
|
||||||
LastSyncSource: pgtype.Text{String: "koreader", Valid: true},
|
LastSyncSource: pgtype.Text{String: "koreader", Valid: true},
|
||||||
ViewportY: pgtype.Float8{},
|
ViewportY: pgtype.Float8{},
|
||||||
@@ -465,97 +445,42 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, userID pgtype.U
|
|||||||
ReadingMode: pgtype.Text{},
|
ReadingMode: pgtype.Text{},
|
||||||
ZoomLevel: pgtype.Float8{},
|
ZoomLevel: pgtype.Float8{},
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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(
|
h.connManager.BroadcastProgressUpdate(
|
||||||
mediaItemID.Bytes,
|
mediaItemID.Bytes,
|
||||||
book.Percentage,
|
book.Percentage,
|
||||||
wsync.SourceDevice{
|
wsync.SourceDevice{
|
||||||
ID: uuid.UUID(userID.Bytes).String(),
|
ID: uuid.UUID(deviceID.Bytes).String(),
|
||||||
Name: deviceInfo.DeviceModel,
|
Name: deviceModel,
|
||||||
Type: "koreader",
|
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 {
|
func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||||
|
|||||||
+96
-12
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
|
wsync "bookhoard/internal/sync"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -125,6 +126,7 @@ type MediaHandler struct {
|
|||||||
worker *services.Worker
|
worker *services.Worker
|
||||||
libraryService *services.LibraryService
|
libraryService *services.LibraryService
|
||||||
searchService *services.SearchService
|
searchService *services.SearchService
|
||||||
|
progressSvc *wsync.ProgressService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
|
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
|
return mh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||||
|
mh.progressSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
||||||
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
||||||
if err != nil {
|
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"})
|
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},
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, 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.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
|
// UpdateMediaReadingProgress handles PUT /api/media-items/:id/progress
|
||||||
@@ -922,24 +948,82 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
CurrentPage int32 `json:"current_page"`
|
CurrentPage *int32 `json:"current_page"`
|
||||||
TotalPages int32 `json:"total_pages"`
|
TotalPages *int32 `json:"total_pages"`
|
||||||
Epubcfi string `json:"epubcfi"`
|
Epubcfi *string `json:"epubcfi"`
|
||||||
Percentage float64 `json:"percentage"`
|
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 {
|
if err := c.Bind(&req); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
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{
|
progress, err := mh.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, 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},
|
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},
|
Chapter: pgtype.Int4{Valid: false},
|
||||||
ChapterProgress: pgtype.Float8{Valid: false},
|
ChapterProgress: pgtype.Float8{Valid: false},
|
||||||
ViewportX: 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},
|
ReadingMode: pgtype.Text{Valid: false},
|
||||||
LastSyncDevice: pgtype.Text{String: "web", Valid: true},
|
LastSyncDevice: pgtype.Text{String: "web", Valid: true},
|
||||||
LastSyncSource: pgtype.Text{String: "web", Valid: true},
|
LastSyncSource: pgtype.Text{String: "web", Valid: true},
|
||||||
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
|
CurrentPage: pgtype.Int4{Int32: currentPage, Valid: true},
|
||||||
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
|
TotalPages: pgtype.Int4{Int32: totalPages, Valid: totalPages > 0},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,3 +29,124 @@ func TestGetDeviceIcon_Unknown(t *testing.T) {
|
|||||||
result = getDeviceIcon("")
|
result = getDeviceIcon("")
|
||||||
assert.Equal(t, "📚", result)
|
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
@@ -36,6 +36,7 @@ const (
|
|||||||
|
|
||||||
type SyncQueueProcessor struct {
|
type SyncQueueProcessor struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
|
progressSvc *ProgressService
|
||||||
progressChan chan *ProgressUpdate
|
progressChan chan *ProgressUpdate
|
||||||
interval time.Duration
|
interval time.Duration
|
||||||
batchSize int
|
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) {
|
func (p *SyncQueueProcessor) Start(ctx context.Context) {
|
||||||
log.Printf("Starting sync queue processor (interval: %v, batch: %d)", p.interval, p.batchSize)
|
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")
|
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
|
var epubcfi pgtype.Text
|
||||||
if v, ok := syncData["epubcfi"].(string); ok {
|
if v, ok := syncData["epubcfi"].(string); ok {
|
||||||
epubcfi = pgtype.Text{String: v, Valid: true}
|
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}
|
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{
|
_, err := p.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
|||||||
Reference in New Issue
Block a user