mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
cc50b55eeb
Add original and text-only EPUB delivery choices across Kobo, KOReader, OPDS, web downloads and email while keeping source files unchanged. Persist server defaults, device overrides and captured artifact identities so retries and guided replacement retain the selected rendition. Preserve saved progress across first opens, replacement resets and stale updates, and project annotations against the copy delivered to each device. Handle XML resource references, large inodes, retry backoff and background worker state correctly. Consolidate the schema changes into one migration and retain released-client request and response compatibility. Verify with the full server and client checks, delivery and sync HTTP suites, independent code reviews, and live KOReader download/settings/reader proof. Fixes #1496 Closes #1603 Closes #1714 Fixes #1723 Ref #1633 Ref #1508 Ref #1621 Ref #1136
771 lines
29 KiB
Lua
771 lines
29 KiB
Lua
--[[--
|
|
HTTP client for the BookOrbit server.
|
|
|
|
Speaks the kosync-compatible progress endpoints plus the BookOrbit plugin
|
|
endpoints. Sync clients run blocking requests in a subprocess when called from
|
|
a Trapper coroutine, keeping KOReader's UI loop responsive.
|
|
]]
|
|
|
|
local http = require("socket.http")
|
|
local ltn12 = require("ltn12")
|
|
local rapidjson = require("rapidjson")
|
|
local socket = require("socket")
|
|
local socketutil = require("socketutil")
|
|
local logger = require("logger")
|
|
local util = require("util")
|
|
|
|
local TransferPolicy = require("bookorbit_transfer_policy")
|
|
local TransferProgress = require("bookorbit_transfer_progress")
|
|
local Proxy = require("bookorbit_proxy")
|
|
|
|
local MAX_BODY_BYTES = 900 * 1024 -- stays under the server's 1 MiB body limit
|
|
local MATCH_HASH_LENGTH = 32
|
|
local MATCH_TITLE_MAX_BYTES = 500
|
|
local MATCH_AUTHORS_MAX_BYTES = 1000
|
|
local MAX_SAFE_INTEGER = 9007199254740991
|
|
|
|
local MAX_DOWNLOAD_REDIRECTS = 5
|
|
|
|
-- Plain empty Lua tables would encode as {} and fail the server's array
|
|
-- validation; every empty table in our payloads is semantically an array.
|
|
local ENCODE_OPTIONS = { empty_table_as_array = true }
|
|
|
|
local function validMatchHash(value)
|
|
return type(value) == "string"
|
|
and #value == MATCH_HASH_LENGTH
|
|
and value:match("^[0-9a-fA-F]+$") ~= nil
|
|
end
|
|
|
|
-- Byte limits are conservative against the server's character limits and
|
|
-- keep a worst-case 500-book payload below the HTTP body cap.
|
|
local function boundedUtf8(value, max_bytes)
|
|
if type(value) ~= "string" then return nil end
|
|
value = util.fixUtf8(value, "?")
|
|
if #value <= max_bytes then return value end
|
|
return util.fixUtf8(value:sub(1, max_bytes), "")
|
|
end
|
|
|
|
local function nonNegativeInteger(value)
|
|
value = tonumber(value)
|
|
if not value or value ~= value or value == math.huge
|
|
or value < 0 or value > MAX_SAFE_INTEGER or value ~= math.floor(value) then
|
|
return nil
|
|
end
|
|
return value
|
|
end
|
|
|
|
local function validMatchSource(value)
|
|
if value == "current_file" or value == "file" or value == "statistics" then
|
|
return value
|
|
end
|
|
end
|
|
|
|
local function optionalBoolean(value)
|
|
if type(value) == "boolean" then return value end
|
|
end
|
|
|
|
-- JSON null decodes to the rapidjson.null lightuserdata, which is truthy in
|
|
-- Lua and would leak into sidecars and truthiness checks; treat it as absent.
|
|
local function scrubNulls(value)
|
|
if value == rapidjson.null then return nil end
|
|
if type(value) == "table" then
|
|
for key, item in pairs(value) do
|
|
value[key] = scrubNulls(item)
|
|
end
|
|
end
|
|
return value
|
|
end
|
|
|
|
local function decodeResponse(parts)
|
|
local raw = table.concat(parts or {})
|
|
if raw == "" then
|
|
return {}
|
|
end
|
|
local ok, decoded, decode_err = pcall(rapidjson.decode, raw)
|
|
if not ok or decoded == nil then
|
|
logger.dbg("BookOrbit: invalid JSON response:", ok and decode_err or decoded)
|
|
return nil, "invalid_json"
|
|
end
|
|
return scrubNulls(decoded) or {}
|
|
end
|
|
|
|
local function encodeSubprocessResult(body, err, errbody)
|
|
local ok, encoded = pcall(rapidjson.encode, {
|
|
body = body,
|
|
err = err,
|
|
errbody = errbody,
|
|
})
|
|
if ok and type(encoded) == "string" and encoded ~= "" then
|
|
return encoded
|
|
end
|
|
return '{"err":"subprocess_result_encode_failed"}'
|
|
end
|
|
|
|
local function decodeSubprocessResult(encoded)
|
|
if type(encoded) ~= "string" or encoded == "" then
|
|
return nil, "subprocess_no_result"
|
|
end
|
|
local decoded = decodeResponse({ encoded })
|
|
if type(decoded) ~= "table" then
|
|
return nil, "subprocess_invalid_result"
|
|
end
|
|
return decoded
|
|
end
|
|
|
|
local function parseHttpUrl(value)
|
|
local scheme, authority = tostring(value or ""):match("^(https?)://([^/%?#]+)")
|
|
if not scheme or not authority or authority:find("@", 1, true) then return nil end
|
|
|
|
local host, port
|
|
if authority:sub(1, 1) == "[" then
|
|
host, port = authority:match("^(%b[]):?(%d*)$")
|
|
else
|
|
host, port = authority:match("^([^:]+):?(%d*)$")
|
|
end
|
|
if not host or host == "" then return nil end
|
|
port = port ~= "" and tonumber(port) or (scheme == "https" and 443 or 80)
|
|
if not port then return nil end
|
|
return {
|
|
scheme = scheme,
|
|
authority = authority,
|
|
host = host:lower(),
|
|
port = port,
|
|
}
|
|
end
|
|
|
|
local function absoluteUrl(base, location)
|
|
if location:match("^https?://") then return location end
|
|
local parsed = parseHttpUrl(base)
|
|
if not parsed then return nil end
|
|
if location:sub(1, 1) == "?" then
|
|
return base:gsub("[?#].*$", "") .. location
|
|
end
|
|
if location:sub(1, 1) == "#" then
|
|
return base:gsub("#.*$", "") .. location
|
|
end
|
|
if location:sub(1, 2) == "//" then
|
|
return parsed.scheme .. ":" .. location
|
|
end
|
|
local origin = parsed.scheme .. "://" .. parsed.authority
|
|
if location:sub(1, 1) == "/" then return origin .. location end
|
|
local directory = base:match("^(.*)/") or origin
|
|
return directory .. "/" .. location
|
|
end
|
|
|
|
local function safeRequestRedirect(initial_url, current_url, location)
|
|
if type(location) ~= "string" or location == "" then return nil end
|
|
local resolved = absoluteUrl(current_url, location:gsub("%s", ""))
|
|
local initial = parseHttpUrl(initial_url)
|
|
local current = parseHttpUrl(current_url)
|
|
local target = parseHttpUrl(resolved)
|
|
if not initial or not current or not target or target.host ~= initial.host then return nil end
|
|
if target.scheme == current.scheme and target.port == current.port then return resolved end
|
|
if current.scheme == "http" and target.scheme == "https" then return resolved end
|
|
return nil
|
|
end
|
|
|
|
local function forkSafeServerUrl(value)
|
|
local scheme, authority, suffix = tostring(value or ""):match("^(https?)://([^/%?#]+)(.*)$")
|
|
if scheme ~= "http" or not authority then return value end
|
|
local port = authority:lower():match("^localhost:(%d+)$")
|
|
if authority:lower() == "localhost" then
|
|
return "http://127.0.0.1" .. suffix
|
|
elseif port then
|
|
return "http://127.0.0.1:" .. port .. suffix
|
|
end
|
|
return value
|
|
end
|
|
|
|
local BookOrbitApi = {}
|
|
BookOrbitApi.__index = BookOrbitApi
|
|
BookOrbitApi.decodeResponse = decodeResponse
|
|
|
|
-- Normalizes a user-entered server address to the API base, e.g.
|
|
-- "https://books.example.com/" -> "https://books.example.com/api/v1".
|
|
function BookOrbitApi.normalizeServerUrl(input)
|
|
if not input then return nil end
|
|
local url = util.trim(input)
|
|
if url == "" then return nil end
|
|
url = url:gsub("/+$", "")
|
|
url = url:gsub("/api/v1/koreader$", "/api/v1")
|
|
if not url:match("/api/v1$") then
|
|
url = url .. "/api/v1"
|
|
end
|
|
return url
|
|
end
|
|
|
|
function BookOrbitApi.new(opts)
|
|
opts = opts or {}
|
|
return setmetatable({
|
|
server_url = forkSafeServerUrl(opts.server_url),
|
|
username = opts.username,
|
|
userkey = opts.userkey,
|
|
device_id = opts.device_id,
|
|
device_model = opts.device_model,
|
|
plugin_version = opts.plugin_version,
|
|
background_requests = opts.background_requests == true,
|
|
proxy = opts.proxy,
|
|
}, BookOrbitApi)
|
|
end
|
|
|
|
-- Resolves the HTTP proxy for a target URL.
|
|
-- Prefers an explicit proxy option, then KOReader's enabled global HTTP proxy.
|
|
function BookOrbitApi:getProxy(target_url)
|
|
local url = (target_url and tostring(target_url):match("^https?://")) and tostring(target_url) or self.server_url
|
|
local parsed = parseHttpUrl(url)
|
|
if not parsed then return nil end
|
|
|
|
if self.proxy and self.proxy ~= "" then
|
|
return self.proxy
|
|
end
|
|
|
|
if G_reader_settings and G_reader_settings:isTrue("http_proxy_enabled") then
|
|
local p = G_reader_settings:readSetting("http_proxy")
|
|
if p and p ~= "" then
|
|
return p
|
|
end
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
local function sendHttpRequest(request)
|
|
if not request.url:lower():match("^https://") then
|
|
return http.request(request)
|
|
end
|
|
|
|
local transport_request = {}
|
|
for key, value in pairs(request) do transport_request[key] = value end
|
|
if request.proxy then
|
|
transport_request.create = Proxy.connectSocket(request.proxy)
|
|
transport_request.proxy = nil
|
|
end
|
|
-- LuaSocket's global proxy overrides a custom HTTPS socket unless cleared.
|
|
local global_proxy = http.PROXY
|
|
http.PROXY = nil
|
|
local ok, result, code, headers, status = pcall(http.request, transport_request)
|
|
http.PROXY = global_proxy
|
|
if not ok then return nil, tostring(result) end
|
|
return result, code, headers, status
|
|
end
|
|
|
|
function BookOrbitApi:isConfigured()
|
|
return self.server_url ~= nil and self.username ~= nil and self.userkey ~= nil
|
|
end
|
|
|
|
-- Returns decoded_body on success, or nil, err_code, decoded_error_body.
|
|
-- err_code is a number for HTTP errors and a string for transport errors.
|
|
function BookOrbitApi:requestBlocking(method, path, body, extra_headers)
|
|
local initial_url = self.server_url .. path
|
|
local request = {
|
|
url = initial_url,
|
|
method = method,
|
|
redirect = false,
|
|
headers = {
|
|
["accept"] = "application/json",
|
|
["x-auth-user"] = self.username,
|
|
["x-auth-key"] = self.userkey,
|
|
},
|
|
}
|
|
|
|
for name, value in pairs(extra_headers or {}) do
|
|
request.headers[name] = value
|
|
end
|
|
|
|
if body then
|
|
local body_json, encode_err = rapidjson.encode(body, ENCODE_OPTIONS)
|
|
if not body_json then
|
|
return nil, "encode_error: " .. tostring(encode_err)
|
|
end
|
|
if #body_json > MAX_BODY_BYTES then
|
|
return nil, "body_too_large"
|
|
end
|
|
request.source = ltn12.source.string(body_json)
|
|
request.headers["Content-Type"] = "application/json"
|
|
request.headers["Content-Length"] = #body_json
|
|
end
|
|
|
|
local current_url = initial_url
|
|
for redirect_count = 0, MAX_DOWNLOAD_REDIRECTS do
|
|
local sink = {}
|
|
request.url = current_url
|
|
request.sink = ltn12.sink.table(sink)
|
|
request.proxy = self:getProxy(current_url)
|
|
|
|
socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT)
|
|
local code, headers, status = socket.skip(1, sendHttpRequest(request))
|
|
socketutil:reset_timeout()
|
|
|
|
if type(code) ~= "number" then
|
|
logger.dbg("BookOrbit: network error:", status or code)
|
|
return nil, tostring(status or code or "network_error")
|
|
end
|
|
|
|
if not request.source and (method == "GET" or method == "HEAD")
|
|
and (code == 301 or code == 302 or code == 303 or code == 307 or code == 308) then
|
|
if redirect_count >= MAX_DOWNLOAD_REDIRECTS then
|
|
return nil, "too_many_redirects"
|
|
end
|
|
local location = headers and (headers.location or headers.Location)
|
|
local redirected = safeRequestRedirect(initial_url, current_url, location)
|
|
if not redirected then return nil, "unsafe_redirect" end
|
|
current_url = redirected
|
|
else
|
|
local decoded, decode_err = decodeResponse(sink)
|
|
if code < 200 or code >= 300 then return nil, code, decoded end
|
|
if decode_err then return nil, decode_err end
|
|
return decoded or {}
|
|
end
|
|
end
|
|
|
|
return nil, "too_many_redirects"
|
|
end
|
|
|
|
-- True when this call may fork its own background worker: the client is in
|
|
-- background mode, a Trapper coroutine is driving us, and no enclosing
|
|
-- runInSubprocess already owns a child for this work.
|
|
function BookOrbitApi:canForkSubprocess()
|
|
if not self.background_requests then return false end
|
|
if (self.subprocess_depth or 0) > 0 then return false end
|
|
local loaded, Trapper = pcall(require, "ui/trapper")
|
|
if not loaded or not Trapper:isWrapped() then return false end
|
|
return true
|
|
end
|
|
|
|
-- Owns exactly one background subprocess for the duration of fn. Calls that
|
|
-- fn makes back into this client see a non-zero depth in the forked child and
|
|
-- run inline, so a request is never wrapped by two nested workers.
|
|
--
|
|
-- fn must return (body, err, errbody); they are marshalled back through the
|
|
-- subprocess. Returns completed, { body, err, errbody }; completed is false
|
|
-- when the user dismissed the request.
|
|
function BookOrbitApi:runInSubprocess(fn, trap_widget)
|
|
if not self:canForkSubprocess() then
|
|
local ok, body, err, errbody = pcall(fn)
|
|
if not ok then return true, { err = tostring(body) } end
|
|
return true, { body = body, err = err, errbody = errbody }
|
|
end
|
|
|
|
local Trapper = require("ui/trapper")
|
|
-- An unmounted widget lets Trapper poll without intercepting reader input.
|
|
local widget = trap_widget or {}
|
|
local completed, encoded = Trapper:dismissableRunInSubprocess(function()
|
|
self.subprocess_depth = (self.subprocess_depth or 0) + 1
|
|
local ok, body, err, errbody = pcall(fn)
|
|
self.subprocess_depth = self.subprocess_depth - 1
|
|
if not ok then
|
|
return encodeSubprocessResult(nil, tostring(body))
|
|
end
|
|
return encodeSubprocessResult(body, err, errbody)
|
|
end, widget, true)
|
|
if type(widget) == "table" then widget.dismiss_callback = nil end
|
|
if not completed then return false end
|
|
local result, decode_err = decodeSubprocessResult(encoded)
|
|
if not result then
|
|
return true, { err = decode_err }
|
|
end
|
|
return true, result
|
|
end
|
|
|
|
function BookOrbitApi:request(method, path, body, extra_headers)
|
|
if not self:canForkSubprocess() then
|
|
return self:requestBlocking(method, path, body, extra_headers)
|
|
end
|
|
|
|
local completed, result = self:runInSubprocess(function()
|
|
return self:requestBlocking(method, path, body, extra_headers)
|
|
end)
|
|
if not completed then
|
|
return nil, "background_request_interrupted"
|
|
end
|
|
return result.body, result.err, result.errbody
|
|
end
|
|
|
|
function BookOrbitApi:query(path, params)
|
|
if not params then return path end
|
|
|
|
local keys = {}
|
|
for key, value in pairs(params) do
|
|
if value ~= nil and value ~= "" then
|
|
table.insert(keys, key)
|
|
end
|
|
end
|
|
table.sort(keys)
|
|
if #keys == 0 then return path end
|
|
|
|
local parts = {}
|
|
for _, key in ipairs(keys) do
|
|
table.insert(parts, util.urlEncode(key) .. "=" .. util.urlEncode(tostring(params[key])))
|
|
end
|
|
return path .. "?" .. table.concat(parts, "&")
|
|
end
|
|
|
|
-- Downloads to a temporary file and publishes it with an atomic rename, so a
|
|
-- timeout, an oversized body or a cancelled generation can never leave a
|
|
-- half-written file where a complete one is expected.
|
|
--
|
|
-- opts: accept, progress_cb, progress_path, progress_generation, temp_path,
|
|
-- max_bytes, expect_content_type, block_timeout, total_timeout, expected_bytes,
|
|
-- publish. With publish = "parent" the complete temporary file is handed back
|
|
-- instead of renamed, so a cancelled generation cannot be published by a child
|
|
-- that was already finishing.
|
|
function BookOrbitApi:downloadBlocking(path, local_path, opts)
|
|
opts = opts or {}
|
|
local temp_path = opts.temp_path or (local_path .. ".part")
|
|
local max_bytes = opts.max_bytes or TransferPolicy.maxBytes(opts.expected_bytes)
|
|
local progress_cb = opts.progress_cb
|
|
local progress_writer = opts.progress_path and TransferProgress.writer(opts.progress_path, {
|
|
generation = opts.progress_generation,
|
|
total = tonumber(opts.expected_bytes) or 0,
|
|
})
|
|
local initial_url = self.server_url .. path
|
|
local current_url = initial_url
|
|
local total_received = 0
|
|
|
|
local function fail(out, err)
|
|
if out then pcall(function() out:close() end) end
|
|
util.removeFile(temp_path)
|
|
return nil, err
|
|
end
|
|
|
|
for redirect_count = 0, MAX_DOWNLOAD_REDIRECTS do
|
|
local out, open_err = io.open(temp_path, "w")
|
|
if not out then
|
|
return nil, tostring(open_err or "open_error")
|
|
end
|
|
|
|
local received = 0
|
|
local too_large = false
|
|
local file_sink = ltn12.sink.file(out)
|
|
local sink = function(chunk, err)
|
|
if chunk and chunk ~= "" then
|
|
received = received + #chunk
|
|
total_received = total_received + #chunk
|
|
if received > max_bytes or total_received > max_bytes then
|
|
too_large = true
|
|
return nil, "response_too_large"
|
|
end
|
|
if progress_cb then progress_cb(received) end
|
|
if progress_writer then progress_writer(received, false) end
|
|
end
|
|
return file_sink(chunk, err)
|
|
end
|
|
|
|
local request = {
|
|
url = current_url,
|
|
method = "GET",
|
|
sink = sink,
|
|
redirect = false,
|
|
headers = {
|
|
["accept"] = opts.accept or "application/octet-stream",
|
|
["Accept-Encoding"] = "identity",
|
|
["x-auth-user"] = self.username,
|
|
["x-auth-key"] = self.userkey,
|
|
},
|
|
}
|
|
|
|
request.proxy = self:getProxy(current_url)
|
|
|
|
socketutil:set_timeout(
|
|
opts.block_timeout or socketutil.FILE_BLOCK_TIMEOUT,
|
|
opts.total_timeout or socketutil.FILE_TOTAL_TIMEOUT)
|
|
local code, headers, status = socket.skip(1, sendHttpRequest(request))
|
|
socketutil:reset_timeout()
|
|
|
|
if too_large then return fail(out, "response_too_large") end
|
|
if type(code) ~= "number" then
|
|
return fail(out, tostring(status or code or "network_error"))
|
|
end
|
|
|
|
if code == 301 or code == 302 or code == 303 or code == 307 or code == 308 then
|
|
pcall(function() out:close() end)
|
|
util.removeFile(temp_path)
|
|
if redirect_count >= MAX_DOWNLOAD_REDIRECTS then
|
|
return nil, "too_many_redirects"
|
|
end
|
|
local location = headers and (headers.location or headers.Location)
|
|
local redirected = safeRequestRedirect(initial_url, current_url, location)
|
|
if not redirected then return nil, "unsafe_redirect" end
|
|
current_url = redirected
|
|
else
|
|
if code ~= 200 then return fail(out, code) end
|
|
if received == 0 then return fail(out, "empty_response") end
|
|
|
|
local expected_type = opts.expect_content_type
|
|
if expected_type then
|
|
local content_type = headers and (headers["content-type"] or headers["Content-Type"])
|
|
local media_type = content_type and tostring(content_type):lower():match("^%s*([^;]+)")
|
|
if not media_type or media_type:sub(1, #expected_type) ~= expected_type then
|
|
return fail(out, "unexpected_content_type")
|
|
end
|
|
end
|
|
|
|
local content_length = headers and tonumber(headers["content-length"] or headers["Content-Length"])
|
|
if (content_length and received ~= content_length) or (opts.expected_bytes and received ~= opts.expected_bytes) then return fail(out, "size_mismatch") end
|
|
if progress_writer then progress_writer(received, true) end
|
|
|
|
-- The parent owns publishing for user-cancellable transfers: a
|
|
-- dismissed subprocess cannot be stopped once it starts renaming,
|
|
-- which is harmless for a cover but wrong for a cancelled book.
|
|
if opts.publish == "parent" then
|
|
local result = { temp_path = temp_path, bytes = received, server_hash = headers and headers["x-bookorbit-file-hash"], artifact_id = headers and headers["x-bookorbit-artifact"] }
|
|
-- Hashing here keeps the digest off the UI thread and lets the
|
|
-- caller compare it to the manifest without a match request.
|
|
if opts.hash == "partial_md5" then
|
|
local hashed, digest = pcall(util.partialMD5, temp_path)
|
|
result.hash = hashed and digest or nil
|
|
end
|
|
return result
|
|
end
|
|
if temp_path == local_path then return true end
|
|
local renamed, rename_err = os.rename(temp_path, local_path)
|
|
if not renamed then
|
|
return fail(out, tostring(rename_err or "publish_failed"))
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
|
|
return nil, "too_many_redirects"
|
|
end
|
|
|
|
-- Runs the transfer in a background subprocess when this client owns no worker
|
|
-- yet. A forked child cannot reach the parent's UI, so live byte progress
|
|
-- travels through the snapshot file named by opts.progress_path; only a caller
|
|
-- that has nothing but an in-process progress_cb stays blocking.
|
|
function BookOrbitApi:download(path, local_path, opts)
|
|
opts = opts or {}
|
|
local blocking = opts.background == false
|
|
or (opts.progress_cb ~= nil and opts.progress_path == nil)
|
|
or not self:canForkSubprocess()
|
|
if blocking then
|
|
return self:downloadBlocking(path, local_path, opts)
|
|
end
|
|
|
|
-- A child's progress_cb would touch parent-only UI state from the forked
|
|
-- process; the snapshot file is the only channel back.
|
|
local child_opts = {}
|
|
for key, value in pairs(opts) do child_opts[key] = value end
|
|
child_opts.progress_cb = nil
|
|
|
|
local completed, result = self:runInSubprocess(function()
|
|
return self:downloadBlocking(path, local_path, child_opts)
|
|
end, opts.trap_widget)
|
|
if not completed then
|
|
return nil, "cancelled"
|
|
end
|
|
return result.body, result.err
|
|
end
|
|
|
|
function BookOrbitApi:withDevice(body)
|
|
body.deviceId = self.device_id
|
|
body.deviceModel = self.device_model
|
|
body.pluginVersion = self.plugin_version
|
|
-- KOReader datetimes are local wall clock with no timezone; the server
|
|
-- needs our clock to mint device datetimes that are not in our future.
|
|
body.deviceTime = os.date("%Y-%m-%d %H:%M:%S")
|
|
return body
|
|
end
|
|
|
|
-- kosync-compatible endpoints (existing BookOrbit API, snake_case wire format)
|
|
|
|
function BookOrbitApi:auth()
|
|
return self:request("GET", "/koreader/users/auth")
|
|
end
|
|
|
|
function BookOrbitApi:getProgress(digest)
|
|
return self:request("GET", "/koreader/syncs/progress/" .. digest)
|
|
end
|
|
|
|
function BookOrbitApi:updateProgress(digest, percentage, progress, timestamp)
|
|
return self:request("PUT", "/koreader/syncs/progress", {
|
|
document = digest,
|
|
percentage = percentage,
|
|
progress = progress,
|
|
device = self.device_model,
|
|
device_id = self.device_id,
|
|
timestamp = timestamp,
|
|
})
|
|
end
|
|
|
|
-- BookOrbit plugin endpoints (camelCase wire format)
|
|
|
|
function BookOrbitApi:matchCheck(hashes, candidates)
|
|
local payload = { hashes = {} }
|
|
if candidates then
|
|
payload.books = {}
|
|
end
|
|
|
|
local skipped = 0
|
|
for _, hash in ipairs(hashes or {}) do
|
|
local valid_hash = validMatchHash(hash)
|
|
if valid_hash then
|
|
table.insert(payload.hashes, hash)
|
|
else
|
|
skipped = skipped + 1
|
|
end
|
|
if candidates and valid_hash then
|
|
local cand = candidates[hash] or {}
|
|
table.insert(payload.books, {
|
|
hash = hash,
|
|
title = boundedUtf8(cand.title, MATCH_TITLE_MAX_BYTES),
|
|
authors = boundedUtf8(cand.authors, MATCH_AUTHORS_MAX_BYTES),
|
|
lastOpen = nonNegativeInteger(cand.last_open),
|
|
source = validMatchSource(cand.source),
|
|
bookFileId = nonNegativeInteger(cand.book_file_id),
|
|
metadataAmbiguous = optionalBoolean(cand.metadata_ambiguous),
|
|
})
|
|
end
|
|
end
|
|
|
|
if skipped > 0 then
|
|
logger.warn("BookOrbit: match-check skipped invalid hashes:", skipped)
|
|
end
|
|
if #payload.hashes == 0 then
|
|
return { matches = {} }
|
|
end
|
|
return self:request("POST", "/koreader/plugin/match-check", self:withDevice(payload))
|
|
end
|
|
|
|
function BookOrbitApi:uploadPageStats(books)
|
|
return self:request("POST", "/koreader/plugin/page-stats", self:withDevice({ books = books }))
|
|
end
|
|
|
|
-- Deprecated one-way upload, kept as fallback for pre-0.4 servers.
|
|
function BookOrbitApi:uploadAnnotations(books)
|
|
return self:request("POST", "/koreader/plugin/annotations", self:withDevice({ books = books }))
|
|
end
|
|
|
|
function BookOrbitApi:exchangeAnnotations(books)
|
|
return self:request("POST", "/koreader/plugin/annotations/exchange", self:withDevice({ books = books }))
|
|
end
|
|
|
|
function BookOrbitApi:exchangeAck(books)
|
|
return self:request("POST", "/koreader/plugin/annotations/exchange-ack", self:withDevice({ books = books }))
|
|
end
|
|
|
|
function BookOrbitApi:exchangeBookmarks(books)
|
|
return self:request("POST", "/koreader/plugin/bookmarks/exchange", self:withDevice({ books = books }))
|
|
end
|
|
|
|
function BookOrbitApi:exchangeBookmarksAck(books)
|
|
return self:request("POST", "/koreader/plugin/bookmarks/exchange-ack", self:withDevice({ books = books }))
|
|
end
|
|
|
|
function BookOrbitApi:uploadBookStates(books)
|
|
return self:request("POST", "/koreader/plugin/book-states", self:withDevice({ books = books }))
|
|
end
|
|
|
|
function BookOrbitApi:bulkProgress(items)
|
|
return self:request("POST", "/koreader/plugin/progress", self:withDevice({ items = items }))
|
|
end
|
|
|
|
function BookOrbitApi:sweepComplete(counts)
|
|
return self:request("POST", "/koreader/plugin/sweeps", self:withDevice({
|
|
booksMatched = counts.books_matched or 0,
|
|
pageStatsUploaded = counts.page_stats or 0,
|
|
annotationsUpserted = counts.annotations or 0,
|
|
}))
|
|
end
|
|
|
|
-- BookOrbit catalog endpoints
|
|
|
|
function BookOrbitApi:catalogRoot()
|
|
return self:request("GET", "/koreader/plugin/catalog/root")
|
|
end
|
|
|
|
-- The dashboard body carries the stats, Continue reading and Discover the
|
|
-- shelves are built from. Each configured shelf is then fetched through the
|
|
-- ordinary catalog endpoints, so nothing here is section-aware.
|
|
function BookOrbitApi:catalogDashboard()
|
|
return self:request("GET", "/koreader/plugin/catalog/dashboard")
|
|
end
|
|
|
|
function BookOrbitApi:catalogDiscover()
|
|
return self:request("GET", "/koreader/plugin/catalog/dashboard/discover")
|
|
end
|
|
|
|
-- One server-composed dashboard section (want-to-read, up-next-in-series).
|
|
-- Only valid against servers advertising the catalogDashboardSections
|
|
-- capability; older ones answer 404.
|
|
function BookOrbitApi:catalogDashboardSection(section_type)
|
|
return self:request("GET", "/koreader/plugin/catalog/dashboard/sections/" .. util.urlEncode(tostring(section_type)))
|
|
end
|
|
|
|
function BookOrbitApi:catalogSection(section, params)
|
|
return self:request("GET", self:query("/koreader/plugin/catalog/sections/" .. util.urlEncode(section), params))
|
|
end
|
|
|
|
function BookOrbitApi:catalogSetReadStatus(book_id, status)
|
|
return self:request("PUT", "/koreader/plugin/catalog/books/" .. tostring(book_id) .. "/read-status", { status = status })
|
|
end
|
|
|
|
function BookOrbitApi:catalogSetRating(book_id, rating)
|
|
if rating == nil then rating = rapidjson.null end
|
|
return self:request("PUT", "/koreader/plugin/catalog/books/" .. tostring(book_id) .. "/rating", { rating = rating })
|
|
end
|
|
|
|
function BookOrbitApi:catalogBooks(params)
|
|
return self:request("GET", self:query("/koreader/plugin/catalog/books", params))
|
|
end
|
|
|
|
-- Cursor-paginated bulk download manifest. One page carries everything
|
|
-- selection and transfer need, so a bulk run issues no per-book detail request.
|
|
function BookOrbitApi:catalogManifest(params)
|
|
params = params or {}
|
|
params.deviceId = params.deviceId or self.device_id
|
|
return self:request("GET", self:query("/koreader/plugin/catalog/manifest", params))
|
|
end
|
|
|
|
function BookOrbitApi:getDeliverySettings()
|
|
return self:request("GET", "/koreader/plugin/delivery-settings/" .. util.urlEncode(tostring(self.device_id)))
|
|
end
|
|
|
|
function BookOrbitApi:setDeliverySettings(variant, migrate_only)
|
|
return self:request("PUT", "/koreader/plugin/delivery-settings/" .. util.urlEncode(tostring(self.device_id)), {
|
|
readAloudDelivery = variant or rapidjson.null,
|
|
migrateOnly = migrate_only == true,
|
|
})
|
|
end
|
|
|
|
function BookOrbitApi:catalogBook(book_id, variant)
|
|
return self:request("GET", self:query("/koreader/plugin/catalog/books/" .. tostring(book_id), { deviceId = self.device_id, variant = variant }))
|
|
end
|
|
|
|
function BookOrbitApi:downloadCatalogFile(file_id, local_path, opts)
|
|
opts = opts or {}
|
|
return self:download(self:query("/koreader/plugin/catalog/files/" .. tostring(file_id) .. "/download", { variant = opts.variant, selection = opts.selection }), local_path, opts)
|
|
end
|
|
|
|
function BookOrbitApi:downloadCatalogThumbnail(book_id, local_path, opts)
|
|
opts = opts or {}
|
|
opts.accept = "image/jpeg,image/*"
|
|
opts.expect_content_type = "image/"
|
|
return self:download("/koreader/plugin/catalog/books/" .. tostring(book_id) .. "/thumbnail", local_path, opts)
|
|
end
|
|
|
|
-- Plugin self-update endpoints
|
|
|
|
function BookOrbitApi:getPluginVersion()
|
|
local headers = {}
|
|
local valid_device_id = type(self.device_id) == "string"
|
|
and #self.device_id <= 100
|
|
and self.device_id:match("^[A-Za-z0-9-]+$") ~= nil
|
|
local valid_plugin_version = type(self.plugin_version) == "string"
|
|
and #self.plugin_version <= 20
|
|
and self.plugin_version:find("[\r\n]") == nil
|
|
if valid_device_id and valid_plugin_version then
|
|
headers["x-bookorbit-device-id"] = self.device_id
|
|
headers["x-bookorbit-plugin-version"] = self.plugin_version
|
|
end
|
|
return self:request("GET", "/koreader/plugin/version", nil, headers)
|
|
end
|
|
|
|
function BookOrbitApi:downloadPluginUpdate(local_path, opts)
|
|
opts = opts or {}
|
|
opts.accept = "application/zip"
|
|
return self:download("/koreader/plugin/package", local_path, opts)
|
|
end
|
|
|
|
return BookOrbitApi
|