--[[-- 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