feat(sync): Add Kobo/Koreader sync and conflict handling
- Add Kobo markup/sync endpoints for bookshelves - Add Koreader progress sync with SHA-256 support - Add sync conflict detection and resolution - Update ebook scanner for better file matching
This commit is contained in:
+199
-91
@@ -32,6 +32,7 @@ type KOReaderProgressRequest struct {
|
||||
|
||||
type KOReaderBookProgress struct {
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Authors []string `json:"authors,omitempty"`
|
||||
Progress float64 `json:"progress"`
|
||||
@@ -64,6 +65,7 @@ type KOReaderBookmark struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Percentage *float64 `json:"percentage,omitempty"`
|
||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderHighlight struct {
|
||||
@@ -77,6 +79,7 @@ type KOReaderHighlight struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
Percentage *float64 `json:"percentage,omitempty"`
|
||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderNote struct {
|
||||
@@ -89,6 +92,7 @@ type KOReaderNote struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Percentage *float64 `json:"percentage,omitempty"`
|
||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderSyncResponse struct {
|
||||
@@ -182,46 +186,14 @@ func (h *KOReaderHandler) SyncProgress(c echo.Context) error {
|
||||
conflicts := []KOReaderConflict{}
|
||||
|
||||
for _, book := range req.Books {
|
||||
var mediaUUID uuid.UUID
|
||||
var err error
|
||||
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
|
||||
if !mediaItemID.Valid {
|
||||
continue
|
||||
}
|
||||
|
||||
if book.UUID != "" {
|
||||
mediaUUID, err = uuid.Parse(book.UUID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err == nil {
|
||||
err = h.updateProgressForBook(c, pgUserID, mediaItem.ID, book)
|
||||
if err == nil {
|
||||
booksSynced++
|
||||
}
|
||||
}
|
||||
} else if book.FilePath != "" {
|
||||
mediaItem, err := h.db.GetMediaItemByFilePath(c.Request().Context(), book.FilePath)
|
||||
if err == nil {
|
||||
err = h.updateProgressForBook(c, pgUserID, mediaItem.ID, book)
|
||||
if err == nil {
|
||||
booksSynced++
|
||||
}
|
||||
}
|
||||
} else if book.Title != "" {
|
||||
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
})
|
||||
if err == nil {
|
||||
for _, mi := range mediaItems {
|
||||
if mi.Title == book.Title && (book.Authors == nil || mi.Author.String == book.Authors[0]) {
|
||||
err = h.updateProgressForBook(c, pgUserID, mi.ID, book)
|
||||
if err == nil {
|
||||
booksSynced++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
err := h.updateProgressForBook(c, pgUserID, mediaItemID, book)
|
||||
if err == nil {
|
||||
booksSynced++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,50 +223,136 @@ func (h *KOReaderHandler) SyncProgress(c echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) resolveBookToMediaItem(c echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, book KOReaderBookProgress) (pgtype.UUID, float64) {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
// Priority 1: UUID provided (highest confidence - 1.0)
|
||||
if book.UUID != "" {
|
||||
mediaUUID, err := uuid.Parse(book.UUID)
|
||||
if err == nil {
|
||||
mediaItem, err := h.db.GetMediaItem(ctx, pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err == nil {
|
||||
// Create device file alias if FilePath is also provided
|
||||
if book.FilePath != "" {
|
||||
h.createDeviceFileAlias(c, deviceID, mediaItem.ID, book)
|
||||
}
|
||||
return mediaItem.ID, 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: SHA-256 provided (medium confidence - 0.9)
|
||||
if book.SHA256 != "" && len(book.SHA256) == 64 {
|
||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: book.SHA256, Valid: true})
|
||||
if err == nil {
|
||||
// Create device file alias if FilePath is provided
|
||||
if book.FilePath != "" {
|
||||
h.createDeviceFileAlias(c, deviceID, mediaItem.ID, book)
|
||||
}
|
||||
return mediaItem.ID, 0.9
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: FilePath provided (check existing alias or create new - confidence 0.7)
|
||||
if book.FilePath != "" {
|
||||
// Check if alias already exists
|
||||
alias, err := h.db.GetDeviceFileAlias(ctx, database.GetDeviceFileAliasParams{
|
||||
DeviceID: deviceID,
|
||||
FilePath: book.FilePath,
|
||||
})
|
||||
if err == nil {
|
||||
return alias.MediaItemID, alias.ConfidenceScore.Float64
|
||||
}
|
||||
|
||||
// Try to find by file path
|
||||
mediaItem, err := h.db.GetMediaItemByFilePath(ctx, book.FilePath)
|
||||
if err == nil {
|
||||
// Create new alias
|
||||
confidence := 0.7
|
||||
h.createDeviceFileAlias(c, deviceID, mediaItem.ID, book)
|
||||
return mediaItem.ID, confidence
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 4: Title + Author match (lowest confidence - 0.5)
|
||||
if book.Title != "" {
|
||||
mediaItems, err := h.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
})
|
||||
if err == nil {
|
||||
for _, mi := range mediaItems {
|
||||
if mi.Title == book.Title {
|
||||
// Check author match if provided
|
||||
if len(book.Authors) > 0 && mi.Author.Valid {
|
||||
if mi.Author.String == book.Authors[0] {
|
||||
// Create device file alias if FilePath is provided
|
||||
if book.FilePath != "" {
|
||||
h.createDeviceFileAlias(c, deviceID, mi.ID, book)
|
||||
}
|
||||
return mi.ID, 0.5
|
||||
}
|
||||
} else {
|
||||
// Title only match
|
||||
if book.FilePath != "" {
|
||||
h.createDeviceFileAlias(c, deviceID, mi.ID, book)
|
||||
}
|
||||
return mi.ID, 0.4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pgtype.UUID{}, 0.0
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) createDeviceFileAlias(c echo.Context, deviceID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) {
|
||||
ctx := c.Request().Context()
|
||||
|
||||
if book.FilePath == "" {
|
||||
return
|
||||
}
|
||||
|
||||
sha256 := book.SHA256
|
||||
if sha256 == "" {
|
||||
// Try to get SHA256 from media item
|
||||
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
||||
if err == nil && mediaItem.FileSha256.Valid {
|
||||
sha256 = mediaItem.FileSha256.String
|
||||
}
|
||||
}
|
||||
|
||||
confidence := 0.7
|
||||
// Check if alias already exists
|
||||
_, err := h.db.GetDeviceFileAlias(ctx, database.GetDeviceFileAliasParams{
|
||||
DeviceID: deviceID,
|
||||
FilePath: book.FilePath,
|
||||
})
|
||||
if err != nil {
|
||||
// Create new alias
|
||||
_, _ = h.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DeviceID: deviceID,
|
||||
FilePath: book.FilePath,
|
||||
FileSha256: pgtype.Text{String: sha256, Valid: sha256 != ""},
|
||||
ConfidenceScore: pgtype.Float8{Float64: confidence, Valid: true},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) handleCheckpointSync(c echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error {
|
||||
booksEnqueued := 0
|
||||
|
||||
for _, book := range req.Books {
|
||||
var mediaUUID uuid.UUID
|
||||
var err error
|
||||
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
|
||||
if !mediaItemID.Valid {
|
||||
continue
|
||||
}
|
||||
|
||||
if book.UUID != "" {
|
||||
mediaUUID, err = uuid.Parse(book.UUID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err == nil {
|
||||
err = h.enqueueProgressForBook(c, device.ID, userID, mediaItem.ID, book)
|
||||
if err == nil {
|
||||
booksEnqueued++
|
||||
}
|
||||
}
|
||||
} else if book.FilePath != "" {
|
||||
mediaItem, err := h.db.GetMediaItemByFilePath(c.Request().Context(), book.FilePath)
|
||||
if err == nil {
|
||||
err = h.enqueueProgressForBook(c, device.ID, userID, mediaItem.ID, book)
|
||||
if err == nil {
|
||||
booksEnqueued++
|
||||
}
|
||||
}
|
||||
} else if book.Title != "" {
|
||||
mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{
|
||||
Limit: 100,
|
||||
Offset: 0,
|
||||
})
|
||||
if err == nil {
|
||||
for _, mi := range mediaItems {
|
||||
if mi.Title == book.Title && (book.Authors == nil || mi.Author.String == book.Authors[0]) {
|
||||
err = h.enqueueProgressForBook(c, device.ID, userID, mi.ID, book)
|
||||
if err == nil {
|
||||
booksEnqueued++
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
|
||||
if err == nil {
|
||||
booksEnqueued++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,7 +736,8 @@ func (h *KOReaderHandler) SyncBookmarks(c echo.Context) error {
|
||||
userID := device.UserID.Bytes
|
||||
|
||||
var req struct {
|
||||
BookUUID string `json:"book_uuid" validate:"required"`
|
||||
BookUUID string `json:"book_uuid,omitempty"`
|
||||
BookSHA256 string `json:"book_sha256,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks"`
|
||||
Notes []KOReaderNote `json:"notes"`
|
||||
Highlights []KOReaderHighlight `json:"highlights"`
|
||||
@@ -696,21 +755,50 @@ func (h *KOReaderHandler) SyncBookmarks(c echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
bookUUID, err := uuid.Parse(req.BookUUID)
|
||||
if err != nil {
|
||||
ctx := c.Request().Context()
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
// Resolve media item ID using priority matching
|
||||
var pgBookUUID pgtype.UUID
|
||||
if req.BookUUID != "" {
|
||||
// Use UUID directly
|
||||
bookUUID, err := uuid.Parse(req.BookUUID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid book UUID",
|
||||
})
|
||||
}
|
||||
pgBookUUID = pgtype.UUID{Bytes: bookUUID, Valid: true}
|
||||
} else if req.BookSHA256 != "" && len(req.BookSHA256) == 64 {
|
||||
// Use SHA-256 to find book
|
||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: req.BookSHA256, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{
|
||||
"error": "book not found by SHA-256",
|
||||
})
|
||||
}
|
||||
pgBookUUID = mediaItem.ID
|
||||
} else {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid book UUID",
|
||||
"error": "either book_uuid or book_sha256 is required",
|
||||
})
|
||||
}
|
||||
|
||||
pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true}
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
bookmarksSynced := 0
|
||||
notesSynced := 0
|
||||
highlightsSynced := 0
|
||||
|
||||
for _, bookmark := range req.Bookmarks {
|
||||
mediaItemID := pgBookUUID
|
||||
|
||||
// If bookmark has its own SHA-256, use it for matching
|
||||
if bookmark.BookSHA256 != "" && len(bookmark.BookSHA256) == 64 {
|
||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: bookmark.BookSHA256, Valid: true})
|
||||
if err == nil {
|
||||
mediaItemID = mediaItem.ID
|
||||
}
|
||||
}
|
||||
|
||||
position := ""
|
||||
if bookmark.Pos0 != "" {
|
||||
position = bookmark.Pos0
|
||||
@@ -718,8 +806,8 @@ func (h *KOReaderHandler) SyncBookmarks(c echo.Context) error {
|
||||
position = fmt.Sprintf("page:%d", bookmark.Page)
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.Text,
|
||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||
@@ -731,6 +819,16 @@ func (h *KOReaderHandler) SyncBookmarks(c echo.Context) error {
|
||||
}
|
||||
|
||||
for _, note := range req.Notes {
|
||||
mediaItemID := pgBookUUID
|
||||
|
||||
// If note has its own SHA-256, use it for matching
|
||||
if note.BookSHA256 != "" && len(note.BookSHA256) == 64 {
|
||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: note.BookSHA256, Valid: true})
|
||||
if err == nil {
|
||||
mediaItemID = mediaItem.ID
|
||||
}
|
||||
}
|
||||
|
||||
position := ""
|
||||
if note.Pos0 != "" {
|
||||
position = note.Pos0
|
||||
@@ -738,8 +836,8 @@ func (h *KOReaderHandler) SyncBookmarks(c echo.Context) error {
|
||||
position = fmt.Sprintf("page:%d", note.Page)
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Content: note.Notes,
|
||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||
@@ -751,6 +849,16 @@ func (h *KOReaderHandler) SyncBookmarks(c echo.Context) error {
|
||||
}
|
||||
|
||||
for _, highlight := range req.Highlights {
|
||||
mediaItemID := pgBookUUID
|
||||
|
||||
// If highlight has its own SHA-256, use it for matching
|
||||
if highlight.BookSHA256 != "" && len(highlight.BookSHA256) == 64 {
|
||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx, pgtype.Text{String: highlight.BookSHA256, Valid: true})
|
||||
if err == nil {
|
||||
mediaItemID = mediaItem.ID
|
||||
}
|
||||
}
|
||||
|
||||
startPos := highlight.Pos0
|
||||
endPos := highlight.Pos1
|
||||
if startPos == "" && highlight.Page > 0 {
|
||||
@@ -763,8 +871,8 @@ func (h *KOReaderHandler) SyncBookmarks(c echo.Context) error {
|
||||
color = highlight.Color
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgBookUUID,
|
||||
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.Text,
|
||||
StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""},
|
||||
|
||||
Reference in New Issue
Block a user