feat(koreader): device bridge for ratings, reviews and reading-time stats
Release / build-and-push (push) Successful in 3m4s

Extends the device-authed koreader surface with the handoff item 4
Batch B contract:

- GET/POST /api/sync/koreader/rating — the device mirrors the .sdr
  summary's 5-star rating against the 1-10 server scale (POST 0 clears).
- GET/POST /api/sync/koreader/review — raw-markdown review mirror
  (summary.note); empty body clears, matching the first-party PUT.
- POST /api/sync/koreader/stats — the device pushes its CUMULATIVE
  total reading seconds; the server watermark (highest total ever
  converted, read from the koreader sessions' own device_metadata)
  converts only the delta into a reading_history session attributed
  to the koreader device. Reinstalls cannot double-count; sub-minute
  deltas ride forward (no session, no watermark movement); a lagging
  device clamps to zero instead of dragging the total back.

Book identity resolves through the existing priority chain (uuid →
sha256 → alias → title/author). GET binding uses query tags; POST
endpoints bind the ref+payload once (an earlier resolve-then-bind
draft drained the body).

Live-verified against the dev server with the koreader device token:
rating set/get/clear, review mirror, and the stats sequence
900→session, 960→delta session, 970→rides forward, 500→clamps; the
book's stats card and the By Device panel both pick up the split.
This commit is contained in:
John O'Keefe committed 2026-10-15 11:26:47 -04:00
1 parent 2117ec6181
commit 2517baedea
5 files changed
+308

No files matched your search

+6
View File
@@ -235,6 +235,12 @@ type Querier interface {
GetKoboShelfBooksByShelfName(ctx context.Context, arg GetKoboShelfBooksByShelfNameParams) ([]GetKoboShelfBooksByShelfNameRow, error) GetKoboShelfBooksByShelfName(ctx context.Context, arg GetKoboShelfBooksByShelfNameParams) ([]GetKoboShelfBooksByShelfNameRow, error)
// Get Kobo shelves by collection // Get Kobo shelves by collection
GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error) 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) GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error)
GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error) GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error)
GetLibraryByFolderPathPrefix(ctx context.Context, folderPath string) (GetLibraryByFolderPathPrefixRow, error) GetLibraryByFolderPathPrefix(ctx context.Context, folderPath string) (GetLibraryByFolderPathPrefixRow, error)
+25
View File
@@ -4624,6 +4624,31 @@ func (q *Queries) GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShe
return items, nil 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 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 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 FROM libraries l
+12
View File
@@ -565,6 +565,18 @@ SELECT * FROM media_reviews WHERE media_item_id = $1 AND user_id = $2;
-- name: DeleteMediaReview :exec -- name: DeleteMediaReview :exec
DELETE FROM media_reviews WHERE media_item_id = $1 AND user_id = $2; 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 -- Search Media Items queries
-- name: SearchMediaItems :many -- name: SearchMediaItems :many
SELECT mi.*, l.name as library_name, lt.name as library_type_name SELECT mi.*, l.name as library_name, lt.name as library_type_name
+260
View File
@@ -6,6 +6,7 @@ import (
wsync "bookhoard/internal/sync" wsync "bookhoard/internal/sync"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
@@ -14,6 +15,8 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/jackc/pgx/v5"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
@@ -1729,3 +1732,260 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
"timestamp": time.Now().Format(time.RFC3339), "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)
}
+5
View File
@@ -28,6 +28,11 @@ func registerSyncRoutes(cfg *Config) {
koreaderSync.GET("/metadata/:uuid", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetMetadata)) koreaderSync.GET("/metadata/:uuid", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetMetadata))
koreaderSync.GET("/library", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetLibrary)) koreaderSync.GET("/library", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetLibrary))
koreaderSync.POST("/bookmarks", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncBookmarks)) 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 sync routes (device authentication required)
// Kobo devices use URL path: /api/sync/kobo/{token}/markup // Kobo devices use URL path: /api/sync/kobo/{token}/markup