diff --git a/internal/database/querier.go b/internal/database/querier.go index 32adbd61..60f429fb 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -235,6 +235,12 @@ type Querier interface { GetKoboShelfBooksByShelfName(ctx context.Context, arg GetKoboShelfBooksByShelfNameParams) ([]GetKoboShelfBooksByShelfNameRow, error) // Get Kobo shelves by collection GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error) + // KOReader stats conversion: the device pushes its CUMULATIVE total reading + // seconds for a book; the watermark is the highest total already converted, + // read from the koreader sessions' own device_metadata. Delta-only insertion + // means a reinstall of the device cannot double-count, and sub-threshold + // deltas are never lost (they stay folded into the next push's delta). + GetKoreaderStatWatermark(ctx context.Context, arg GetKoreaderStatWatermarkParams) (interface{}, error) GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error) GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error) GetLibraryByFolderPathPrefix(ctx context.Context, folderPath string) (GetLibraryByFolderPathPrefixRow, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 88764de2..07556e85 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -4624,6 +4624,31 @@ func (q *Queries) GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShe return items, nil } +const GetKoreaderStatWatermark = `-- name: GetKoreaderStatWatermark :one +SELECT COALESCE(MAX((device_metadata->>'total_seconds')::bigint), 0) AS total_seconds +FROM reading_history +WHERE user_id = $1 + AND media_item_id = $2 + AND device_metadata->>'source' = 'koreader' +` + +type GetKoreaderStatWatermarkParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` +} + +// KOReader stats conversion: the device pushes its CUMULATIVE total reading +// seconds for a book; the watermark is the highest total already converted, +// read from the koreader sessions' own device_metadata. Delta-only insertion +// means a reinstall of the device cannot double-count, and sub-threshold +// deltas are never lost (they stay folded into the next push's delta). +func (q *Queries) GetKoreaderStatWatermark(ctx context.Context, arg GetKoreaderStatWatermarkParams) (interface{}, error) { + row := q.db.QueryRow(ctx, GetKoreaderStatWatermark, arg.UserID, arg.MediaItemID) + var total_seconds interface{} + err := row.Scan(&total_seconds) + return total_seconds, err +} + const GetLibrary = `-- name: GetLibrary :one SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description FROM libraries l diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index c8b67283..46909b48 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -565,6 +565,18 @@ SELECT * FROM media_reviews WHERE media_item_id = $1 AND user_id = $2; -- name: DeleteMediaReview :exec DELETE FROM media_reviews WHERE media_item_id = $1 AND user_id = $2; +-- KOReader stats conversion: the device pushes its CUMULATIVE total reading +-- seconds for a book; the watermark is the highest total already converted, +-- read from the koreader sessions' own device_metadata. Delta-only insertion +-- means a reinstall of the device cannot double-count, and sub-threshold +-- deltas are never lost (they stay folded into the next push's delta). +-- name: GetKoreaderStatWatermark :one +SELECT COALESCE(MAX((device_metadata->>'total_seconds')::bigint), 0) AS total_seconds +FROM reading_history +WHERE user_id = $1 + AND media_item_id = $2 + AND device_metadata->>'source' = 'koreader'; + -- Search Media Items queries -- name: SearchMediaItems :many SELECT mi.*, l.name as library_name, lt.name as library_type_name diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index 21bae124..f2d63626 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -6,6 +6,7 @@ import ( wsync "bookhoard/internal/sync" "context" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -14,6 +15,8 @@ import ( "time" "unicode/utf8" + "github.com/jackc/pgx/v5" + "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" @@ -1729,3 +1732,260 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error { "timestamp": time.Now().Format(time.RFC3339), }) } + +// --------------------------------------------------------------------------- +// Ratings / review / reading-time bridge (handoff item 4 Batch B) +// +// The plugin mirrors the book's .sdr summary (summary.rating 5-star, +// summary.note = the Book Status "Review" box) against the server's +// 1-10 rating and raw-markdown review, and pushes its cumulative +// reading time from statistics.sqlite3. Stats are PUSH-ONLY and +// watermark-converted: the device sends its TOTAL seconds for the +// book and the server inserts only the delta as a reading_history +// session attributed to this device, so totals unify in the one +// stats pipeline while the By Device panel keeps the per-client +// split. A device reinstall cannot double-count (the watermark is +// server-side), and sub-threshold deltas simply ride forward. +// --------------------------------------------------------------------------- + +// koreaderBookRef is the minimal book identity the bridge endpoints +// accept; it resolves through the same priority chain as progress. +type koreaderBookRef struct { + UUID string `json:"uuid,omitempty" query:"uuid"` + SHA256 string `json:"sha256,omitempty" query:"sha256"` + Title string `json:"title,omitempty" query:"title"` + Authors []string `json:"authors,omitempty"` +} + +func (r koreaderBookRef) toProgress() KOReaderBookProgress { + return KOReaderBookProgress{ + UUID: r.UUID, + SHA256: r.SHA256, + Title: r.Title, + Authors: r.Authors, + } +} + +// resolveParsedRef resolves an already-bound book identity to a media +// item. Endpoints bind ONCE — a resolve helper that re-bound the body +// would drain it and break the payload bind that follows. +func (h *KOReaderHandler) resolveParsedRef(c *echo.Context, device database.Devices, ref koreaderBookRef) (pgtype.UUID, error) { + mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, device.UserID, ref.toProgress()) + if !mediaItemID.Valid { + return pgtype.UUID{}, fmt.Errorf("book not found on server") + } + return mediaItemID, nil +} + +// GetKOReaderRating handles GET /api/sync/koreader/rating?uuid=&sha256= +func (h *KOReaderHandler) GetKOReaderRating(c *echo.Context) error { + device := c.Get("device").(database.Devices) + var ref koreaderBookRef + if err := c.Bind(&ref); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + mediaItemID, err := h.resolveParsedRef(c, device, ref) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + rating, err := h.db.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{ + MediaItemID: mediaItemID, + UserID: device.UserID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]interface{}{"rating": rating.Rating}) +} + +// SetKOReaderRating handles POST /api/sync/koreader/rating. Rating 0 +// (koreader's "unrated") clears the server rating. +func (h *KOReaderHandler) SetKOReaderRating(c *echo.Context) error { + device := c.Get("device").(database.Devices) + + var req struct { + koreaderBookRef + Rating int `json:"rating" validate:"min=0,max=10"` + } + 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()}) + } + + mediaItemID, err := h.resolveParsedRef(c, device, req.koreaderBookRef) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + + if req.Rating <= 0 { + if err := h.db.DeleteMediaRating(c.Request().Context(), database.DeleteMediaRatingParams{ + MediaItemID: mediaItemID, + UserID: device.UserID, + }); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil}) + } + + rating, err := h.db.CreateMediaRating(c.Request().Context(), database.CreateMediaRatingParams{ + MediaItemID: mediaItemID, + UserID: device.UserID, + Rating: int32(req.Rating), + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]interface{}{"rating": rating.Rating}) +} + +// GetKOReaderReview handles GET /api/sync/koreader/review. Returns the +// RAW MARKDOWN source (koreader's review box shows it literally, the +// same accepted tradeoff as notes). +func (h *KOReaderHandler) GetKOReaderReview(c *echo.Context) error { + device := c.Get("device").(database.Devices) + var ref koreaderBookRef + if err := c.Bind(&ref); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + mediaItemID, err := h.resolveParsedRef(c, device, ref) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + review, err := h.db.GetMediaReview(c.Request().Context(), database.GetMediaReviewParams{ + MediaItemID: mediaItemID, + UserID: device.UserID, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return c.JSON(http.StatusOK, map[string]interface{}{"review": nil}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]interface{}{"review": review.Review}) +} + +// SetKOReaderReview handles POST /api/sync/koreader/review. An empty +// body clears the review (mirrors the first-party PUT contract). +func (h *KOReaderHandler) SetKOReaderReview(c *echo.Context) error { + device := c.Get("device").(database.Devices) + + var req struct { + koreaderBookRef + Review string `json:"review" validate:"max=50000"` + } + 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()}) + } + + mediaItemID, err := h.resolveParsedRef(c, device, req.koreaderBookRef) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + + if strings.TrimSpace(req.Review) == "" { + if err := h.db.DeleteMediaReview(c.Request().Context(), database.DeleteMediaReviewParams{ + MediaItemID: mediaItemID, + UserID: device.UserID, + }); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]interface{}{"review": nil}) + } + + review, err := h.db.UpsertMediaReview(c.Request().Context(), database.UpsertMediaReviewParams{ + MediaItemID: mediaItemID, + UserID: device.UserID, + Review: req.Review, + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]interface{}{"review": review.Review}) +} + +// PushKOReaderStats handles POST /api/sync/koreader/stats. The device +// sends its cumulative total reading seconds for the book; only the +// delta above the stored watermark becomes a reading_history session +// (attributed to the koreader device, metadata carrying the new +// cumulative marker). Sub-minute deltas ride forward: no session, no +// watermark movement, nothing lost. +func (h *KOReaderHandler) PushKOReaderStats(c *echo.Context) error { + device := c.Get("device").(database.Devices) + + var req struct { + koreaderBookRef + TotalSeconds int64 `json:"total_seconds" validate:"min=0,max=2000000000"` + } + 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()}) + } + + mediaItemID, err := h.resolveParsedRef(c, device, req.koreaderBookRef) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + + // sqlc scans COALESCE(MAX(...)) as interface{}; pgx hands bigint + // back as int64. + watermarkAny, err := h.db.GetKoreaderStatWatermark(c.Request().Context(), database.GetKoreaderStatWatermarkParams{ + UserID: device.UserID, + MediaItemID: mediaItemID, + }) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + watermark, _ := watermarkAny.(int64) + + // Delta against the stored watermark (the highest total ever + // converted for this user+book across ALL koreader devices). When + // another device is ahead, the delta clamps to zero so a lagging + // device can never drag the total backwards. + delta := req.TotalSeconds - watermark + if delta < 0 { + delta = 0 + } + + result := map[string]interface{}{ + "total_seconds": req.TotalSeconds, + "watermark": watermark, + "delta_seconds": delta, + "session_recorded": false, + } + + if delta >= 60 { + now := time.Now() + start := now.Add(-time.Duration(delta) * time.Second) + metadata, _ := json.Marshal(map[string]interface{}{ + "source": "koreader", + "total_seconds": req.TotalSeconds, + }) + if _, err := h.db.CreateReadingHistory(c.Request().Context(), database.CreateReadingHistoryParams{ + UserID: device.UserID, + MediaItemID: mediaItemID, + DeviceID: device.ID, + ProgressPercentage: pgtype.Float8{Float64: 0, Valid: false}, + ReadingSessionStart: pgtype.Timestamptz{Time: start, Valid: true}, + ReadingSessionEnd: pgtype.Timestamptz{Time: now, Valid: true}, + PagesRead: pgtype.Int4{Int32: 0, Valid: false}, + TimeSpentSeconds: pgtype.Int4{Int32: int32(delta), Valid: true}, + DeviceMetadata: metadata, + }); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + result["session_recorded"] = true + result["session_seconds"] = delta + } + + return c.JSON(http.StatusOK, result) +} diff --git a/internal/router/sync.go b/internal/router/sync.go index 3e0db37b..cf62c2e9 100644 --- a/internal/router/sync.go +++ b/internal/router/sync.go @@ -28,6 +28,11 @@ func registerSyncRoutes(cfg *Config) { koreaderSync.GET("/metadata/:uuid", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetMetadata)) koreaderSync.GET("/library", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetLibrary)) koreaderSync.POST("/bookmarks", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncBookmarks)) + koreaderSync.GET("/rating", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetKOReaderRating)) + koreaderSync.POST("/rating", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SetKOReaderRating)) + koreaderSync.GET("/review", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetKOReaderReview)) + koreaderSync.POST("/review", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SetKOReaderReview)) + koreaderSync.POST("/stats", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.PushKOReaderStats)) // Kobo sync routes (device authentication required) // Kobo devices use URL path: /api/sync/kobo/{token}/markup