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:
+91
-83
@@ -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++
|
||||
|
||||
+59
-134
@@ -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 {
|
||||
|
||||
+96
-12
@@ -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()})
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user