feat(koreader): device bridge for ratings, reviews and reading-time stats
Release / build-and-push (push) Successful in 3m4s
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:
1 parent
2117ec6181
commit
2517baedea
5 files changed
+308
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user