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).
143 lines
4.0 KiB
Lua
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
|