Fix 1 - File modification time for created_at: - Get file.ModTime() in processMediaFile and pass to CreateMediaItem - Modified SQL INSERT to include created_at column Fix 2 - Force rescan UPDATE instead of DELETE+INSERT: - Changed force rescan logic to call updateMediaItem instead of delete + create - Preserves created_at timestamp on force rescan Fix 3 - GetMediaItemByFilePath filters by library_id: - Added library_id to WHERE clause in SQL query - Created GetMediaItemByFilePathAnyLibrary for cross-library lookups (KOReader) - Added SetLibraryID method to MediaScanner - Updated handler to call SetLibraryID for watch mode Fix 4 - File deletion handling with persistent logging: - Added fsnotify.Remove handler in WatchChanges - Added orphan cleanup in ScanFolders after scan completes - Created scanner_logger.go with daily log rotation (7 days) - Logs to /app/logs/scanner-deletes-YYYY-MM-DD.log and scanner-errors-YYYY-MM-DD.log - Individual deletes with enhanced safety logging Note: Integration tests can now safely scan /app/uploads because GetMediaItemByFilePath now filters by library_id, preventing cross-library interference.
897 lines
27 KiB
Go
897 lines
27 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
wsync "bookhoard/internal/sync"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v4"
|
|
)
|
|
|
|
type KOReaderHandler struct {
|
|
db *database.Queries
|
|
connManager *wsync.ConnectionManager
|
|
queue *wsync.SyncQueueProcessor
|
|
}
|
|
|
|
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
|
|
return &KOReaderHandler{db: db, connManager: connManager, queue: queue}
|
|
}
|
|
|
|
type KOReaderProgressRequest struct {
|
|
LibraryID *string `json:"library_id,omitempty"`
|
|
Books []KOReaderBookProgress `json:"books" validate:"required"`
|
|
SyncMode string `json:"sync_mode,omitempty"`
|
|
}
|
|
|
|
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"`
|
|
Percentage float64 `json:"percentage"`
|
|
LastRead string `json:"last_read,omitempty"`
|
|
FilePath string `json:"file_path,omitempty"`
|
|
DeviceInfo KOReaderDeviceInfo `json:"device_info,omitempty"`
|
|
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
|
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
|
Notes []KOReaderNote `json:"notes,omitempty"`
|
|
Chapter *int `json:"chapter,omitempty"`
|
|
Character *int64 `json:"character,omitempty"`
|
|
Epubcfi *string `json:"epubcfi,omitempty"`
|
|
Page *int `json:"page,omitempty"`
|
|
TotalPages *int `json:"total_pages,omitempty"`
|
|
}
|
|
|
|
type KOReaderDeviceInfo struct {
|
|
KOReaderVersion string `json:"koreader_version,omitempty"`
|
|
DeviceModel string `json:"device_model,omitempty"`
|
|
}
|
|
|
|
type KOReaderBookmark struct {
|
|
Chapter int `json:"chapter,omitempty"`
|
|
Datetime string `json:"datetime,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Pos0 string `json:"pos0,omitempty"`
|
|
Pos1 string `json:"pos1,omitempty"`
|
|
Page int `json:"page,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Percentage *float64 `json:"percentage,omitempty"`
|
|
BookSHA256 string `json:"book_sha256,omitempty"`
|
|
}
|
|
|
|
type KOReaderHighlight struct {
|
|
Chapter int `json:"chapter,omitempty"`
|
|
Datetime string `json:"datetime,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Pos0 string `json:"pos0,omitempty"`
|
|
Pos1 string `json:"pos1,omitempty"`
|
|
Page int `json:"page,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Color string `json:"color,omitempty"`
|
|
Percentage *float64 `json:"percentage,omitempty"`
|
|
BookSHA256 string `json:"book_sha256,omitempty"`
|
|
}
|
|
|
|
type KOReaderNote struct {
|
|
Chapter int `json:"chapter,omitempty"`
|
|
Datetime string `json:"datetime,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Pos0 string `json:"pos0,omitempty"`
|
|
Pos1 string `json:"pos1,omitempty"`
|
|
Page int `json:"page,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
Percentage *float64 `json:"percentage,omitempty"`
|
|
BookSHA256 string `json:"book_sha256,omitempty"`
|
|
}
|
|
|
|
type KOReaderSyncResponse struct {
|
|
SyncStatus string `json:"sync_status"`
|
|
BooksSynced int `json:"books_synced"`
|
|
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
|
Timestamp string `json:"timestamp"`
|
|
DeviceUpdated bool `json:"device_updated"`
|
|
}
|
|
|
|
type KOReaderConflict struct {
|
|
BookUUID string `json:"book_uuid"`
|
|
ConflictType string `json:"conflict_type"`
|
|
DeviceProgress float64 `json:"device_progress"`
|
|
ServerProgress float64 `json:"server_progress"`
|
|
Resolution string `json:"resolution"`
|
|
}
|
|
|
|
type KOReaderMetadata struct {
|
|
UUID string `json:"uuid"`
|
|
Title string `json:"title"`
|
|
Authors []string `json:"authors"`
|
|
Progress KOReaderProgressData `json:"progress"`
|
|
Annotations KOReaderAnnotations `json:"annotations"`
|
|
LastSync string `json:"last_sync"`
|
|
}
|
|
|
|
type KOReaderProgressData struct {
|
|
Percentage float64 `json:"percentage"`
|
|
Character *int64 `json:"character,omitempty"`
|
|
Epubcfi *string `json:"epubcfi,omitempty"`
|
|
Chapter *int `json:"chapter,omitempty"`
|
|
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
|
Page *int `json:"page,omitempty"`
|
|
TotalPages *int `json:"total_pages,omitempty"`
|
|
}
|
|
|
|
type KOReaderAnnotations struct {
|
|
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
|
Notes []KOReaderNote `json:"notes,omitempty"`
|
|
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
|
}
|
|
|
|
type KOReaderLibraryResponse struct {
|
|
LibrarySync []KOReaderLibraryBook `json:"library_sync"`
|
|
TotalBooks int `json:"total_books"`
|
|
LastSync string `json:"last_sync"`
|
|
}
|
|
|
|
type KOReaderLibraryBook struct {
|
|
UUID string `json:"uuid"`
|
|
Title string `json:"title"`
|
|
Author string `json:"author"`
|
|
ContentType string `json:"content_type"`
|
|
PercentRead float64 `json:"percent_read"`
|
|
PagesRemaining *int `json:"pages_remaining,omitempty"`
|
|
BookmarkCount int `json:"bookmark_count"`
|
|
LastModified string `json:"last_modified"`
|
|
}
|
|
|
|
func (h *KOReaderHandler) SyncProgress(c echo.Context) error {
|
|
device := c.Get("device").(database.Devices)
|
|
userID := device.UserID.Bytes
|
|
|
|
var req KOReaderProgressRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "invalid request format",
|
|
"details": err.Error(),
|
|
})
|
|
}
|
|
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
|
|
|
syncMode := req.SyncMode
|
|
if syncMode == "" {
|
|
syncMode = "immediate"
|
|
}
|
|
|
|
if syncMode == "checkpoint" {
|
|
return h.handleCheckpointSync(c, device, pgUserID, req)
|
|
}
|
|
|
|
booksSynced := 0
|
|
conflicts := []KOReaderConflict{}
|
|
|
|
for _, book := range req.Books {
|
|
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
|
|
if !mediaItemID.Valid {
|
|
continue
|
|
}
|
|
|
|
err := h.updateProgressForBook(c, pgUserID, mediaItemID, book)
|
|
if err == nil {
|
|
booksSynced++
|
|
}
|
|
}
|
|
|
|
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to update device timestamp",
|
|
})
|
|
}
|
|
|
|
if syncMode == "immediate" {
|
|
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
|
SyncStatus: "accepted",
|
|
BooksSynced: booksSynced,
|
|
Conflicts: conflicts,
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
DeviceUpdated: true,
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, KOReaderSyncResponse{
|
|
SyncStatus: "completed",
|
|
BooksSynced: booksSynced,
|
|
Conflicts: conflicts,
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
DeviceUpdated: true,
|
|
})
|
|
}
|
|
|
|
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 (search any library)
|
|
mediaItem, err := h.db.GetMediaItemByFilePathAnyLibrary(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 {
|
|
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
|
|
if !mediaItemID.Valid {
|
|
continue
|
|
}
|
|
|
|
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
|
|
if err == nil {
|
|
booksEnqueued++
|
|
}
|
|
}
|
|
|
|
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to update device timestamp",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusAccepted, map[string]interface{}{
|
|
"sync_status": "checkpoint_enqueued",
|
|
"books_enqueued": booksEnqueued,
|
|
"message": "Sync will be processed in the background",
|
|
"timestamp": time.Now().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (h *KOReaderHandler) enqueueProgressForBook(c echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
|
|
if h.queue == nil {
|
|
return fmt.Errorf("sync queue not available")
|
|
}
|
|
|
|
update := &wsync.ProgressUpdate{
|
|
DeviceID: deviceID,
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
Percentage: book.Percentage,
|
|
Epubcfi: book.Epubcfi,
|
|
Chapter: book.Chapter,
|
|
Character: book.Character,
|
|
Page: book.Page,
|
|
TotalPages: book.TotalPages,
|
|
Source: "koreader",
|
|
SyncMode: "checkpoint",
|
|
}
|
|
|
|
return h.queue.EnqueueProgress(update)
|
|
}
|
|
|
|
func (h *KOReaderHandler) updateProgressForBook(c echo.Context, 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,
|
|
})
|
|
|
|
if err != nil && err != pgx.ErrNoRows {
|
|
return err
|
|
}
|
|
|
|
hasExistingProgress := 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{
|
|
MediaItemID: mediaItemID,
|
|
UserID: userID,
|
|
Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
|
Epubcfi: epubcfi,
|
|
Chapter: chapter,
|
|
ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
|
CharacterOffset: characterOffset,
|
|
CurrentPage: currentPage,
|
|
TotalPages: totalPages,
|
|
LastSyncDevice: pgtype.Text{String: "koreader", Valid: true},
|
|
LastSyncSource: pgtype.Text{String: "koreader", Valid: true},
|
|
ViewportY: pgtype.Float8{},
|
|
ScrollPositionX: pgtype.Float8{},
|
|
ScrollPositionY: pgtype.Float8{},
|
|
PanelNumber: pgtype.Int4{},
|
|
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(
|
|
uuid.UUID(mediaItemID.Bytes),
|
|
book.Percentage,
|
|
wsync.SourceDevice{
|
|
ID: uuid.UUID(userID.Bytes).String(),
|
|
Name: deviceInfo.DeviceModel,
|
|
Type: "koreader",
|
|
},
|
|
)
|
|
|
|
_, err = h.db.UpdateDeviceLastSync(ctx, pgtype.UUID{Bytes: [16]byte{}, Valid: false})
|
|
|
|
return err
|
|
}
|
|
|
|
func (h *KOReaderHandler) GetMetadata(c echo.Context) error {
|
|
device := c.Get("device").(database.Devices)
|
|
userID := device.UserID.Bytes
|
|
|
|
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "invalid book UUID",
|
|
})
|
|
}
|
|
|
|
pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true}
|
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
|
|
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{
|
|
"error": "book not found",
|
|
})
|
|
}
|
|
|
|
progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
|
MediaItemID: pgBookUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
|
|
if err != nil {
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"uuid": bookUUID,
|
|
"title": mediaItem.Title,
|
|
"author": mediaItem.Author,
|
|
"progress": nil,
|
|
"annotations": map[string][]interface{}{},
|
|
})
|
|
}
|
|
|
|
progressData := KOReaderProgressData{
|
|
Percentage: progress.Percentage.Float64,
|
|
}
|
|
|
|
if progress.Epubcfi.Valid {
|
|
progressData.Epubcfi = &progress.Epubcfi.String
|
|
}
|
|
if progress.Chapter.Valid {
|
|
ch := int(progress.Chapter.Int32)
|
|
progressData.Chapter = &ch
|
|
}
|
|
if progress.ChapterProgress.Valid {
|
|
cp := progress.ChapterProgress.Float64
|
|
progressData.ChapterProgress = &cp
|
|
}
|
|
if progress.CharacterOffset.Valid {
|
|
co := int64(progress.CharacterOffset.Int64)
|
|
progressData.Character = &co
|
|
}
|
|
if progress.CurrentPage.Valid {
|
|
cp := int(progress.CurrentPage.Int32)
|
|
progressData.Page = &cp
|
|
}
|
|
if progress.TotalPages.Valid {
|
|
tp := int(progress.TotalPages.Int32)
|
|
progressData.TotalPages = &tp
|
|
}
|
|
|
|
annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
|
MediaItemID: pgBookUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
|
|
annotationsResponse := KOReaderAnnotations{
|
|
Highlights: []KOReaderHighlight{},
|
|
Notes: []KOReaderNote{},
|
|
Bookmarks: []KOReaderBookmark{},
|
|
}
|
|
|
|
for _, ann := range annotations {
|
|
if ann.AnnotationType == "highlight" {
|
|
annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{
|
|
Text: ann.SelectionText,
|
|
Pos0: ann.StartPosition.String,
|
|
Pos1: ann.EndPosition.String,
|
|
Color: ann.Color.String,
|
|
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
|
})
|
|
} else if ann.AnnotationType == "note" {
|
|
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
|
Text: ann.SelectionText,
|
|
Pos0: ann.StartPosition.String,
|
|
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
|
})
|
|
}
|
|
}
|
|
|
|
lastSync := "never"
|
|
if progress.LastSyncTimestamp.Valid {
|
|
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
|
|
}
|
|
|
|
metadata := KOReaderMetadata{
|
|
UUID: bookUUID.String(),
|
|
Title: mediaItem.Title,
|
|
Authors: []string{mediaItem.Author.String},
|
|
Progress: progressData,
|
|
Annotations: annotationsResponse,
|
|
LastSync: lastSync,
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, metadata)
|
|
}
|
|
|
|
func (h *KOReaderHandler) GetLibrary(c echo.Context) error {
|
|
device := c.Get("device").(database.Devices)
|
|
userID := device.UserID.Bytes
|
|
|
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
|
|
|
mediaItems, err := h.db.GetUserMediaItemsForSync(c.Request().Context(), pgUserID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to fetch library",
|
|
})
|
|
}
|
|
|
|
libraryBooks := []KOReaderLibraryBook{}
|
|
|
|
for _, item := range mediaItems {
|
|
pgItemUUID := pgtype.UUID{Bytes: item.ID.Bytes, Valid: true}
|
|
|
|
progress, err := h.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
|
MediaItemID: pgItemUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
|
|
percentRead := 0.0
|
|
var pagesRemaining *int
|
|
bookmarkCount := 0
|
|
lastModified := time.Now().Format(time.RFC3339)
|
|
|
|
if err == nil {
|
|
percentRead = progress.Percentage.Float64
|
|
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
|
|
remaining := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32)
|
|
pagesRemaining = &remaining
|
|
}
|
|
if progress.LastReadAt.Valid {
|
|
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
|
|
}
|
|
}
|
|
|
|
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
|
MediaItemID: pgItemUUID,
|
|
UserID: pgUserID,
|
|
})
|
|
bookmarkCount = len(annotations)
|
|
|
|
libraryBooks = append(libraryBooks, KOReaderLibraryBook{
|
|
UUID: uuid.UUID(item.ID.Bytes).String(),
|
|
Title: item.Title,
|
|
Author: item.Author.String,
|
|
ContentType: "6",
|
|
PercentRead: percentRead * 100,
|
|
PagesRemaining: pagesRemaining,
|
|
BookmarkCount: bookmarkCount,
|
|
LastModified: lastModified,
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, KOReaderLibraryResponse{
|
|
LibrarySync: libraryBooks,
|
|
TotalBooks: len(libraryBooks),
|
|
LastSync: time.Now().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (h *KOReaderHandler) SyncBookmarks(c echo.Context) error {
|
|
device := c.Get("device").(database.Devices)
|
|
userID := device.UserID.Bytes
|
|
|
|
var req struct {
|
|
BookUUID string `json:"book_uuid,omitempty"`
|
|
BookSHA256 string `json:"book_sha256,omitempty"`
|
|
Bookmarks []KOReaderBookmark `json:"bookmarks"`
|
|
Notes []KOReaderNote `json:"notes"`
|
|
Highlights []KOReaderHighlight `json:"highlights"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "invalid request format",
|
|
})
|
|
}
|
|
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
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": "either book_uuid or book_sha256 is required",
|
|
})
|
|
}
|
|
|
|
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
|
|
} else if bookmark.Page > 0 {
|
|
position = fmt.Sprintf("page:%d", bookmark.Page)
|
|
}
|
|
|
|
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
|
MediaItemID: mediaItemID,
|
|
UserID: pgUserID,
|
|
Content: bookmark.Text,
|
|
Position: pgtype.Text{String: position, Valid: position != ""},
|
|
})
|
|
|
|
if err == nil {
|
|
bookmarksSynced++
|
|
}
|
|
}
|
|
|
|
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
|
|
} else if note.Page > 0 {
|
|
position = fmt.Sprintf("page:%d", note.Page)
|
|
}
|
|
|
|
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
|
MediaItemID: mediaItemID,
|
|
UserID: pgUserID,
|
|
Content: note.Notes,
|
|
Position: pgtype.Text{String: position, Valid: position != ""},
|
|
})
|
|
|
|
if err == nil {
|
|
notesSynced++
|
|
}
|
|
}
|
|
|
|
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 {
|
|
startPos = fmt.Sprintf("page:%d", highlight.Page)
|
|
endPos = startPos
|
|
}
|
|
|
|
color := "#ffff00"
|
|
if highlight.Color != "" {
|
|
color = highlight.Color
|
|
}
|
|
|
|
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
|
MediaItemID: mediaItemID,
|
|
UserID: pgUserID,
|
|
SelectionText: highlight.Text,
|
|
StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""},
|
|
EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""},
|
|
Color: pgtype.Text{String: color, Valid: true},
|
|
})
|
|
|
|
if err == nil {
|
|
highlightsSynced++
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"sync_status": "completed",
|
|
"bookmarks_synced": bookmarksSynced,
|
|
"notes_synced": notesSynced,
|
|
"highlights_synced": highlightsSynced,
|
|
"total_synced": bookmarksSynced + notesSynced + highlightsSynced,
|
|
"timestamp": time.Now().Format(time.RFC3339),
|
|
})
|
|
}
|