Files
bookhoard.koplugin/BookhoardAPI.lua
T
john-okeefe 9467bdb2bd fix(sync): link fresh books via read-only SHA-256 resolve before first pull
Downloading a book from OPDS and opening it for the first time left the
plugin without a cached bookhoard UUID, so it bootstrapped the book's
identity by pushing once. That push carried the device's first-page
position, which the server stored as a real progress update for a book
that could be mid-read from another source — tripping the cross-source
conflict detector and clobbering genuine progress before the first pull
could deliver it. Until the conflict was resolved on the web, the device
could not pull the correct progress at all.

- onReaderReady now always pulls first; a pull no longer delegates to a
  push to learn the book's identity
- new _linkBookThenPull: resolves the UUID with a read-only
  GET /api/sync/koreader/resolve?sha256= call (BookhoardAPI:resolveBook),
  caches it in the document sidecar, then performs the pull — the
  device's position is never transmitted
- the legacy push-bootstrap survives only as a background fallback for
  books the server does not know by hash (404, sideloaded); interactive
  pulls are told to push explicitly instead

Requires the server-side resolve endpoint (bookhoard feat(sync): add
read-only KOReader book resolve endpoint).
2026-08-22 09:57:16 -04:00

143 lines
4.0 KiB
Lua

local json = require("json")
local json_util = require("json.util")
local logger = require("logger")
local ltn12 = require("ltn12")
local socketutil = require("socketutil")
local http = require("socket.http")
local ssl_ok, ssl_https = pcall(require, "ssl.https")
local PROGRESS_TIMEOUTS = { 2, 5 }
local AUTH_TIMEOUTS = { 5, 10 }
local BookhoardAPI = {
server_url = nil,
auth_token = nil,
}
function BookhoardAPI:new(o)
o = o or {}
setmetatable(o, self)
self.__index = self
return o
end
function BookhoardAPI:_buildHeaders()
local headers = {
["Content-Type"] = "application/json",
["Accept"] = "application/json",
}
if self.auth_token then
headers["Authorization"] = "Bearer " .. self.auth_token
end
return headers
end
function BookhoardAPI:_request(method, path, body, timeouts)
if not self.server_url then
return false, { error = "server URL not configured" }
end
local url = self.server_url .. path
local sink = {}
local headers = self:_buildHeaders()
local body_str = body and json.encode(body) or nil
if body_str then
headers["Content-Length"] = tostring(#body_str)
end
local request = {
url = url,
method = method,
headers = headers,
sink = ltn12.sink.table(sink),
}
if body_str then
request.source = ltn12.source.string(body_str)
end
socketutil:set_timeout(timeouts[1], timeouts[2])
local ok, err, code
if url:match("^https://") then
if ssl_ok then
ok, err, code = pcall(ssl_https.request, request)
else
socketutil:reset_timeout()
return false, { error = "HTTPS not supported on this device" }
end
else
ok, err, code = pcall(http.request, request)
end
socketutil:reset_timeout()
if not ok then
logger.warn("BookhoardAPI: request failed:", err)
return false, { error = tostring(err) }
end
local response_body = table.concat(sink)
local status = tonumber(code)
if status and status >= 200 and status < 300 then
if response_body and #response_body > 0 then
local decode_ok, data = pcall(json.decode, response_body)
if decode_ok and type(data) == "table" then
return true, data
end
return true, response_body
end
return true, nil
end
local error_msg = "HTTP " .. tostring(code)
if response_body and #response_body > 0 then
local decode_ok, data = pcall(json.decode, response_body)
if decode_ok and data and data.error then
error_msg = data.error
end
end
logger.warn("BookhoardAPI:", method, path, "→", status, error_msg)
return false, { status = status, error = error_msg }
end
function BookhoardAPI:registerDevice(device_name, device_identifier)
return self:_request("POST", "/api/devices/register", {
device_name = device_name,
device_type = "koreader",
device_identifier = device_identifier,
}, AUTH_TIMEOUTS)
end
function BookhoardAPI:checkRegistrationStatus(registration_id)
return self:_request("POST", "/api/devices/register/status", {
registration_id = registration_id,
}, AUTH_TIMEOUTS)
end
function BookhoardAPI:syncProgress(book_data, sync_mode)
local books = json_util.InitArray({ book_data })
return self:_request("POST", "/api/sync/koreader/progress", {
books = books,
sync_mode = sync_mode or "immediate",
}, PROGRESS_TIMEOUTS)
end
-- Read-only SHA-256 → book UUID lookup. Used to link a freshly downloaded
-- book WITHOUT pushing progress (a push would transmit the device's
-- first-page position and clobber/conflict with real server progress).
function BookhoardAPI:resolveBook(sha256)
return self:_request("GET", "/api/sync/koreader/resolve?sha256=" .. sha256, nil, PROGRESS_TIMEOUTS)
end
function BookhoardAPI:getMetadata(uuid)
return self:_request("GET", "/api/sync/koreader/metadata/" .. uuid, nil, PROGRESS_TIMEOUTS)
end
return BookhoardAPI