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:
2026-01-31 22:32:09 -05:00
parent 63008001c6
commit d28002e4cb
4 changed files with 728 additions and 134 deletions
+199 -91
View File
@@ -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 != ""},