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)
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user