Files
neon 80244ea4e2 feat(koreader): rebuild the plugin interface and fix four-way annotation sync
Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
2026-10-20 16:05:27 -06:00

785 lines
30 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 type(input) ~= "string" then return nil end
local url = util.trim(input)
if url == "" or url:find("[%s%c%?#]") then return nil end
local parsed = parseHttpUrl(url)
if not parsed or parsed.port < 1 or parsed.port > 65535 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 key ~= "on_device_filter" and 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:annotationPendingBooks(hashes)
return self:request("POST", "/koreader/plugin/annotations/pending", self:withDevice({ hashes = hashes }))
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
-- An author's portrait, from servers advertising catalogListsV2.
function BookOrbitApi:downloadCatalogAuthorPhoto(author_id, local_path, opts)
opts = opts or {}
opts.accept = "image/jpeg,image/*"
opts.expect_content_type = "image/"
return self:download("/koreader/plugin/catalog/authors/" .. tostring(author_id) .. "/photo", 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