Files
readest/apps/readest.koplugin/library/syncbooks.lua
T
Huang Xin 72233e1c6a feat: sync reading status across devices and with KOReader (#4634) (#4656)
* docs(sync): design spec for syncing reading status (#4634)

Field-level LWW for reading_status (dedicated reading_status_updated_at),
a new 'abandoned' status in the Readest UI, and a koplugin bridge to
KOReader's native summary.status (whole-library apply + capture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sync): implementation plan for syncing reading status (#4634)

Bite-sized TDD tasks across 3 parts: A) cloud field-level LWW
(reading_status_updated_at on server upsert + client pull-merge),
B) 'abandoned'/On-hold status in the Readest UI, C) koplugin bridge
to KOReader summary.status (mapping + reconcile + whole-library
apply/capture).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): add reading_status_updated_at for field-level status LWW (#4634)

* feat(sync): stamp readingStatusUpdatedAt on status change in updateBookProgress

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): stamp status timestamp on explicit library status edits

* feat(sync): resolve reading status by its own timestamp in client pull-merge

* feat(sync): resolve reading status by its own timestamp in server upsert (#4634)

* fix(sync): tighten reading-status merge typing + strengthen test (A5 review)

Replace as-unknown-as double-casts at read sites with typed locals
(clientBook/serverBook); retain a single as-unknown-as only at the
server-wins construction site where the static type is too narrow.
Strengthen test 3 to assert both fields with toEqual.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(library): render the 'On hold' (abandoned) status badge

* feat(library): add 'Mark as On hold' actions + i18n for abandoned status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* i18n: translate 'On hold' and 'Mark as On hold' for the abandoned status (#4634)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(koplugin): add reading-status mapping + reconcile between Readest and KOReader

* feat(koplugin): persist + sync reading_status_updated_at in LibraryStore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(koplugin): bridge reading status to KOReader summary.status on library sync (#4634)

* test(sync): cover koplugin v1->v2 migration + tighten status-sync tests (final review)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(sync): redesign KOReader first-sync (decisive-only + bootstrap) (#4634)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(koplugin): safe first-sync of reading status (decisive-only + bootstrap) (#4634)

KOReader auto-sets summary.status='reading' on first open, and legacy Readest
statuses have reading_status_updated_at=0, so pure timestamp LWW let opening a
finished book downgrade it. Restrict sync to deliberate statuses (finished/
complete, abandoned/on-hold, unread->clear); never capture KO 'reading'/'New'.
On the unsynced baseline (Readest ts=0) conflicts resolve Readest-authoritative,
then stamp now_ms to exit bootstrap into steady-state LWW. reconcile now returns
write_ko/write_store flags; statussync captures now_ms once and equalizes both
sides (convergent, idempotent, resumable).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(koplugin): cover remaining first-sync graph cells + document sort effect (review)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 05:00:40 +02:00

967 lines
43 KiB
Lua

-- syncbooks.lua
-- Sync layer for the Library view. Pulls book records from /sync, requests
-- signed download URLs from /storage/download, and streams cloud-only book
-- files + cover images to disk.
--
-- The pure helpers (build_file_key, build_cover_key, build_local_filename,
-- resolve_collision) are exported and unit-tested in
-- spec/library/syncbooks_spec.lua. The network-touching methods (pullBooks,
-- downloadBook, downloadCover) require live KOReader services (Spore,
-- httpclient, NetworkMgr, UIManager.looper) and are exercised via the manual
-- test matrix in docs/library-design.md, not unit tests — stubbing that
-- surface would balloon test setup with little additional confidence.
local M = {}
local EXTS = require("library.exts")
-- ---------------------------------------------------------------------------
-- Constants
-- ---------------------------------------------------------------------------
-- Cloud storage layout under each user's bucket prefix. Web side calls this
-- CLOUD_BOOKS_SUBDIR ("Readest/Books") at apps/readest-app/src/services/constants.ts:35.
local CLOUD_BOOKS_SUBDIR = "Readest/Books"
-- The server's /storage/download fallback accepts any 5-part fileKey shaped
-- like {user_id}/Readest/Books/{book_hash}/{anything}.{ext} and resolves it
-- via a (book_hash, file_key endsWith .ext) lookup in the `files` table —
-- see apps/readest-app/src/pages/api/storage/download.ts:99-107. We therefore
-- send the simple S3-style {hash}/{hash}.{ext} variant; on R2 deployments the
-- fallback transparently rewrites to the actual stored filename.
-- ---------------------------------------------------------------------------
-- build_file_key: cloud download fileKey for a book file
-- ---------------------------------------------------------------------------
function M.build_file_key(book)
if not book then return nil end
if not book.user_id or book.user_id == "" then return nil end
if not book.hash or book.hash == "" then return nil end
local ext = EXTS[book.format]
if not ext then return nil end
return string.format("%s/%s/%s/%s.%s",
book.user_id, CLOUD_BOOKS_SUBDIR, book.hash, book.hash, ext)
end
-- ---------------------------------------------------------------------------
-- build_cover_key: cloud download fileKey for a cover image
-- ---------------------------------------------------------------------------
-- The cover filename is `cover.png` regardless of storage type — same on R2
-- and S3 per `apps/readest-app/src/utils/book.ts:32`. No extension switching.
function M.build_cover_key(book)
if not book then return nil end
if not book.user_id or book.user_id == "" then return nil end
if not book.hash or book.hash == "" then return nil end
return string.format("%s/%s/%s/cover.png",
book.user_id, CLOUD_BOOKS_SUBDIR, book.hash)
end
-- ---------------------------------------------------------------------------
-- build_local_filename: where downloaded book bytes land on disk
-- ---------------------------------------------------------------------------
-- Per design doc: KOReader users prefer flat dirs in their book folder, so
-- downloads land at {library_download_dir}/{safe_title}.{ext} — no nested
-- {hash}/ subdir. The local filename does NOT need to byte-match what Readest
-- writes on the cloud-side filesystem: the only consumer is KOReader's own
-- FileManager, so a simple Lua-native sanitizer is enough (no JS-parity port
-- of makeSafeFilename).
local MAX_BODY_LEN = 200 -- bytes; leaves room for ".extension" suffix
function M.build_local_filename(book)
if not book then return nil end
local ext = EXTS[book.format]
if not ext then return nil end
local raw = book.source_title or book.title or ""
if raw == "" then return "book." .. ext end
-- Replace filesystem-illegal chars + control chars (bytes 0x00-0x1F) with _
-- Safe set covers Windows + macOS + ext4: < > : " / \ | ? * and 0x00-0x1F
local safe = raw:gsub('[<>:|"?*\\/%c]', "_")
-- Byte-clamp; raw byte length is what file systems care about. We may
-- truncate mid-codepoint here, but downstream display is via FileManager
-- which renders bytes as "?" rather than crashing. For v1 we accept this
-- edge — long titles are rare and the user has the cloud copy regardless.
if #safe > MAX_BODY_LEN then
safe = safe:sub(1, MAX_BODY_LEN)
end
-- A pure-_ result (e.g. title was just "????") would round-trip to a
-- weird filename — fall back to "book" in that case.
if safe:match("^_+$") then
safe = "book"
end
return safe .. "." .. ext
end
-- ---------------------------------------------------------------------------
-- resolve_collision: bumps {name}.ext → {name} (1).ext on filename clash
-- ---------------------------------------------------------------------------
-- Takes a candidate filename and a `exists(name) -> bool` predicate; returns
-- a name that doesn't collide. Caller (downloadBook) supplies a predicate
-- that calls lfs.attributes on the destination dir.
function M.resolve_collision(candidate, exists)
if not exists(candidate) then return candidate end
-- Split on the LAST dot so multi-dot titles ("Foo. Vol. 1.epub") still
-- bump correctly: base = "Foo. Vol. 1", ext = "epub".
local base, ext = candidate:match("^(.+)%.([^.]+)$")
if not base then
base = candidate
ext = nil
end
for n = 1, 99 do
local probe
if ext then
probe = string.format("%s (%d).%s", base, n, ext)
else
probe = string.format("%s (%d)", base, n)
end
if not exists(probe) then return probe end
end
-- Should never happen in practice; user has bigger problems if it does.
return candidate
end
-- ---------------------------------------------------------------------------
-- row_to_wire(row) — convert our internal snake_case row to the
-- camelCase Book shape Readest's server expects on POST /sync (mirrors
-- transformBookToDB at apps/readest-app/src/utils/transform.ts:66-105 —
-- but inverted, reading FROM the local row INTO the camelCase wire
-- format that the server itself converts back to DB shape).
-- ---------------------------------------------------------------------------
local function row_to_wire(row)
if not row then return nil end
-- ljsqlite3 returns INTEGER columns as int64_t cdata; dkjson chokes
-- on cdata and the request fails with status=nil. Re-cast defensively.
local function num(v) return v and tonumber(v) or v end
local out = {
bookHash = row.hash,
hash = row.hash,
metaHash = row.meta_hash,
format = row.format,
title = row.title,
author = row.author,
sourceTitle = row.source_title,
groupId = row.group_id,
groupName = row.group_name,
readingStatus = row.reading_status,
readingStatusUpdatedAt = num(row.reading_status_updated_at),
createdAt = num(row.created_at),
updatedAt = num(row.updated_at),
deletedAt = num(row.deleted_at),
uploadedAt = num(row.uploaded_at),
}
-- metadata: server stringifies what we send, so pass the parsed
-- table (NOT the metadata_json string, or it'd get double-encoded).
if row.metadata_json and row.metadata_json ~= "" then
local json = require("json")
local ok, parsed = pcall(json.decode, row.metadata_json)
if ok and type(parsed) == "table" then out.metadata = parsed end
end
-- progress: stored locally as JSON tuple [cur, total] in progress_lib;
-- the wire format expects the actual array.
if row.progress_lib and row.progress_lib ~= "" then
local json = require("json")
local ok, parsed = pcall(json.decode, row.progress_lib)
if ok and type(parsed) == "table" then out.progress = parsed end
end
return out
end
M._row_to_wire = row_to_wire -- exported for tests
-- ---------------------------------------------------------------------------
-- pushBook(book_row, opts, cb) — POST a single book row to /sync. Used
-- after touchBook bumps updated_at on reader close, mirroring what
-- Readest web does after every reading session.
--
-- opts: { sync_auth, sync_path, settings }
-- cb: function(success, msg, status)
-- ---------------------------------------------------------------------------
function M.pushBook(book_row, opts, cb)
local logger = require("logger")
local SyncAuth = opts.sync_auth
if not book_row or not book_row.hash then
if cb then cb(false, "missing book row") end
return
end
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok)
if not ok then
if cb then cb(false, "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
if cb then cb(false, "no sync client") end
return
end
local payload = {
books = { row_to_wire(book_row) },
notes = {},
configs = {},
}
logger.info("ReadestLibrary pushBook: hash=" .. book_row.hash)
client:pushChanges(payload, function(success, body, status)
logger.info("ReadestLibrary pushBook done: success=" .. tostring(success)
.. " status=" .. tostring(status))
if not success then
if cb then cb(false, body and body.error or "push failed", status) end
return
end
if cb then cb(true) end
end)
end)
end
-- ---------------------------------------------------------------------------
-- pushChangedBooks(opts, cb) — push every book row that's changed since the
-- watermark, in one /sync POST (server batches internally). Mirrors
-- useBooksSync's getNewBooks → syncBooks(newBooks, 'push') flow at
-- apps/readest-app/src/app/library/hooks/useBooksSync.ts:88-94.
--
-- After a successful push, advances the watermark to the max
-- updated_at | deleted_at of the rows we sent (so the next sync's
-- getChangedBooks query doesn't re-include them).
-- ---------------------------------------------------------------------------
function M.pushChangedBooks(opts, cb)
local logger = require("logger")
local SyncAuth = opts.sync_auth
local store = opts.store
if not store then
if cb then cb(false, "no store") end
return
end
local since = store:getLastPulledAt() or 0
local changed = store:getChangedBooks(since)
if #changed == 0 then
logger.info("ReadestLibrary pushChangedBooks: nothing to push (since=" .. since .. ")")
if cb then cb(true, 0) end
return
end
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok)
if not ok then
if cb then cb(false, "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
if cb then cb(false, "no sync client") end
return
end
local books_wire = {}
local max_ts = since
for i, row in ipairs(changed) do
books_wire[i] = row_to_wire(row)
if row.updated_at and row.updated_at > max_ts then max_ts = row.updated_at end
if row.deleted_at and row.deleted_at > max_ts then max_ts = row.deleted_at end
end
logger.info("ReadestLibrary pushChangedBooks: pushing " .. #books_wire
.. " row(s) (since=" .. tostring(since)
.. " new_watermark=" .. tostring(max_ts) .. ")")
client:pushChanges({ books = books_wire, notes = {}, configs = {} },
function(success, body, status)
logger.info("ReadestLibrary pushChangedBooks done: success=" .. tostring(success)
.. " status=" .. tostring(status))
if not success then
if cb then cb(false, body and body.error or "push failed", status) end
return
end
store:setLastPulledAt(max_ts)
if cb then cb(true, #books_wire) end
end)
end)
end
-- ---------------------------------------------------------------------------
-- syncBooks(opts, mode, cb, before_push) — convenience wrapper for the
-- bidirectional sync the web does on auto-sync (useBooksSync.handleAutoSync).
-- Modes:
-- "push" — pushChangedBooks only
-- "pull" — pullBooks only (existing fetch)
-- "both" — pull then push (closes #4138)
-- cb is invoked once after the LAST step completes; intermediate failures
-- are logged but do not abort (pull failure shouldn't prevent push).
--
-- before_push (optional): callback invoked AFTER pull and BEFORE push in
-- "both" / "push" modes. Callers use this to bump updated_at on the open
-- book so its touched row gets included in the push delta — but crucially,
-- AFTER pull has refreshed the local row with the cloud's uploaded_at /
-- metadata / group_id. Doing the touch before pull (the original ordering)
-- meant the push could send a row with those fields nil, and the server's
-- transformBookToDB explicit-nulls uploaded_at and metadata for any field
-- absent in the wire payload — wiping the cloud copy on every device.
-- See apps/readest-app/src/utils/transform.ts:99,103.
-- ---------------------------------------------------------------------------
function M.syncBooks(opts, mode, cb, before_push)
mode = mode or "both"
if mode == "push" then
if before_push then before_push() end
M.pushChangedBooks(opts, cb)
elseif mode == "pull" then
M.pullBooks(opts, cb)
else -- "both"
M.pullBooks(opts, function(pull_ok, pull_msg, pull_status)
if before_push then before_push() end
M.pushChangedBooks(opts, function(push_ok, push_msg)
if cb then
cb(pull_ok and push_ok,
string.format("pull=%s/%s push=%s/%s",
tostring(pull_ok), tostring(pull_msg),
tostring(push_ok), tostring(push_msg)),
pull_status)
end
end)
end)
end
end
-- ---------------------------------------------------------------------------
-- Network methods (live KOReader required; not unit-tested)
-- ---------------------------------------------------------------------------
-- See spec for `M.pullBooks`, `M.downloadBook`, `M.downloadCover` in the
-- design doc's Sync flow section. Implementation lives below; structured so
-- the pure helpers stay separately exportable for tests.
-- pullBooks(opts, cb)
-- opts: {
-- sync_auth = SyncAuth instance (for withFreshToken),
-- sync_path = path to the koplugin dir (for the spore spec lookup),
-- settings = current G_reader_settings.readest_sync,
-- store = LibraryStore instance,
-- }
-- cb: function(success, msg)
function M.pullBooks(opts, cb)
local logger = require("logger")
local SyncAuth = opts.sync_auth
local LibraryStore = require("library.librarystore")
logger.info("ReadestLibrary syncbooks.pullBooks: starting")
-- Ensure JWT is fresh before issuing the call (codex round 1: today's
-- ensureClient() refreshes async-and-races; the new wrapper blocks until
-- the refresh completes, so the request never fires with a stale token).
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok, err)
logger.info("ReadestLibrary withFreshToken result: ok=" .. tostring(ok) .. " err=" .. tostring(err))
if not ok then
if cb then cb(false, err or "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
logger.warn("ReadestLibrary getReadestSyncClient returned nil; settings={"
.. "access_token=" .. tostring(opts.settings.access_token and "<set>" or "<nil>")
.. ", expires_at=" .. tostring(opts.settings.expires_at)
.. ", now=" .. tostring(os.time()) .. "}")
if cb then cb(false, "no sync client (not authenticated?)") end
return
end
local since = opts.store:getLastPulledAt() or 0
logger.info("ReadestLibrary client:pullBooks dispatching with since=" .. tostring(since))
client:pullBooks({ since = since }, function(success, body, status)
logger.info("ReadestLibrary client:pullBooks responded: success=" .. tostring(success)
.. " status=" .. tostring(status)
.. " body_type=" .. type(body)
.. " rows=" .. tostring(body and body.books and #body.books or "n/a"))
if not success then
if status == 401 or status == 403
or (body and body.error == "Not authenticated") then
if cb then cb(false, "auth", status) end
else
if cb then cb(false, body and body.error or "pull failed", status) end
end
return
end
local rows = body and body.books or {}
local max_ts = 0
local upserted = 0
for _, raw in ipairs(rows) do
local parsed = LibraryStore.parseSyncRow(raw)
if parsed then
parsed.user_id = opts.settings.user_id
opts.store:upsertBook(parsed)
upserted = upserted + 1
-- Watermark = max of returned updated_at | deleted_at,
-- not local now (codex round 1, finding 8).
if parsed.updated_at and parsed.updated_at > max_ts then
max_ts = parsed.updated_at
end
if parsed.deleted_at and parsed.deleted_at > max_ts then
max_ts = parsed.deleted_at
end
end
end
if max_ts > 0 then opts.store:setLastPulledAt(max_ts) end
logger.info("ReadestLibrary pullBooks complete: rows=" .. #rows
.. " upserted=" .. upserted .. " new_watermark=" .. max_ts)
if cb then cb(true, upserted) end
end)
end)
end
-- downloadBook(book, opts, cb)
-- opts: {
-- sync_auth, sync_path, settings,
-- download_dir = absolute path; created if missing,
-- }
-- book: a row from LibraryStore (must include hash, format, title/source_title)
-- cb: function(success, abs_path_or_err, status)
function M.downloadBook(book, opts, cb)
local logger = require("logger")
local lfs = require("libs/libkoreader-lfs")
local SyncAuth = opts.sync_auth
logger.info("ReadestLibrary downloadBook: hash=" .. tostring(book.hash)
.. " format=" .. tostring(book.format)
.. " title=" .. tostring(book.title))
local file_key = M.build_file_key({
user_id = opts.settings.user_id,
hash = book.hash,
format = book.format,
})
if not file_key then
logger.warn("ReadestLibrary downloadBook: build_file_key returned nil"
.. " (user_id_set=" .. tostring(opts.settings.user_id ~= nil)
.. " hash_set=" .. tostring(book.hash ~= nil and book.hash ~= "")
.. " format=" .. tostring(book.format) .. ")")
if cb then cb(false, "could not build cloud fileKey for book") end
return
end
logger.info("ReadestLibrary downloadBook: file_key=" .. file_key)
local local_name = M.build_local_filename(book)
if not local_name then
logger.warn("ReadestLibrary downloadBook: build_local_filename returned nil")
if cb then cb(false, "unknown book format") end
return
end
if not lfs.attributes(opts.download_dir, "mode") then
logger.info("ReadestLibrary downloadBook: creating download_dir " .. tostring(opts.download_dir))
lfs.mkdir(opts.download_dir)
end
local exists = function(name)
return lfs.attributes(opts.download_dir .. "/" .. name, "mode") ~= nil
end
local final_name = M.resolve_collision(local_name, exists)
local dst = opts.download_dir .. "/" .. final_name
logger.info("ReadestLibrary downloadBook: dst=" .. dst)
logger.info("ReadestLibrary downloadBook: requesting fresh token…")
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok)
logger.info("ReadestLibrary downloadBook: withFreshToken returned ok=" .. tostring(ok))
if not ok then
if cb then cb(false, "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
logger.warn("ReadestLibrary downloadBook: getReadestSyncClient returned nil")
if cb then cb(false, "no sync client") end
return
end
logger.info("ReadestLibrary downloadBook: dispatching getDownloadUrl…")
client:getDownloadUrl({ fileKey = file_key }, function(success, body, status)
logger.info("ReadestLibrary downloadBook: getDownloadUrl responded"
.. " success=" .. tostring(success)
.. " status=" .. tostring(status)
.. " body_type=" .. type(body)
.. " has_url=" .. tostring(body and body.downloadUrl ~= nil))
if not success or not body or not body.downloadUrl then
local err = (status == 404) and "cloud-not-found"
or (body and body.error)
or "url-fetch-failed"
if cb then cb(false, err, status) end
return
end
local url = body.downloadUrl
logger.info("ReadestLibrary downloadBook: streaming GET " .. url:sub(1, 80) .. "…")
-- Use socket.http synchronously with ltn12 file sink — same
-- pattern as KOReader's OPDS downloader (opdsbrowser.lua:1036)
-- and dropboxapi (dropboxapi.lua:39). The async httpclient
-- path we used before only fires its callback inside an
-- active Spore coroutine; calling it from a getDownloadUrl
-- callback doesn't satisfy that, so the response was never
-- delivered and the "Downloading…" dialog hung forever.
-- Synchronous blocks the UI for the duration of the
-- download, which matches OPDS UX (progress dialog stays
-- visible; users expect the brief freeze).
local socket = require("socket")
local http = require("socket.http")
local socketutil = require("socketutil")
local ltn12 = require("ltn12")
local f, ferr = io.open(dst, "wb")
if not f then
logger.warn("ReadestLibrary downloadBook: io.open failed " .. tostring(ferr))
if cb then cb(false, "open dst failed: " .. tostring(ferr)) end
return
end
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
local code, headers, http_status = socket.skip(1, http.request{
url = url,
headers = { ["Accept-Encoding"] = "identity" },
sink = ltn12.sink.file(f),
})
socketutil:reset_timeout()
logger.info("ReadestLibrary downloadBook: socket.http response"
.. " code=" .. tostring(code) .. " status=" .. tostring(http_status))
if code ~= 200 then
-- sink already closed by ltn12 on error; remove the
-- partial file so a retry doesn't trip the
-- collision-resolution suffix.
os.remove(dst)
if cb then cb(false, "download failed", code) end
return
end
logger.info("ReadestLibrary downloadBook: wrote " .. dst)
if cb then cb(true, dst) end
end)
end)
end
-- downloadCover(book, opts, cb)
-- opts: {
-- sync_auth, sync_path, settings,
-- covers_dir = absolute path to readest_covers cache,
-- }
-- cb: function(success, abs_path_or_err, status)
-- A 404 is recorded as a success-with-no-cover so we don't retry forever:
-- callers should set cover_path = "_missing" sentinel when status == 404.
function M.downloadCover(book, opts, cb)
local lfs = require("libs/libkoreader-lfs")
local SyncAuth = opts.sync_auth
local file_key = M.build_cover_key({
user_id = opts.settings.user_id,
hash = book.hash,
})
if not file_key then
if cb then cb(false, "missing user_id or hash") end
return
end
if not lfs.attributes(opts.covers_dir, "mode") then
lfs.mkdir(opts.covers_dir)
end
local dst = opts.covers_dir .. "/" .. book.hash .. ".png"
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok)
if not ok then
if cb then cb(false, "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
if cb then cb(false, "no sync client") end
return
end
client:getDownloadUrl({ fileKey = file_key }, function(success, body, status)
if status == 404 then
if cb then cb(false, "no-cover", 404) end
return
end
if not success or not body or not body.downloadUrl then
if cb then cb(false, "url-fetch-failed", status) end
return
end
-- Background download via fork+poll. The UI stays fully
-- responsive because the actual blocking IO happens in the
-- subprocess; the parent just polls every 300ms to see if
-- it's done. Same pattern KOReader's BookInfoManager uses
-- for cover extraction (bookinfomanager.lua:721).
-- httpclient/Turbo would be nicer but isn't available on
-- platforms KOReader builds without UIManager.looper
-- (macOS desktop, etc).
local FFIUtil = require("ffi/util")
local UIManager = require("ui/uimanager")
local url = body.downloadUrl
local pid, parent_read_fd = FFIUtil.runInSubProcess(
function(_child_pid, child_write_fd)
-- Runs in a forked child. Two hard rules, both to keep
-- KOReader alive on Boox / Adreno devices (issue #4165):
--
-- 1. No Lua error may escape this function. An uncaught
-- error unwinds back to KOReader's android_main,
-- which terminates the child through the libc exit()
-- path — running __cxa_finalize.
-- 2. Terminate via _exit(), never exit(): __cxa_finalize
-- runs the destructor of the GL driver inherited from
-- the parent, which segfaults on Adreno and takes the
-- whole app down with it.
--
-- A network failure in http.request is exactly the kind
-- of error rule 1 guards against, so wrap the body.
local result
local ok, err = pcall(function()
local socket = require("socket")
local http = require("socket.http")
local socketutil = require("socketutil")
local ltn12 = require("ltn12")
local f, ferr = io.open(dst, "wb")
if not f then
result = "error:open:" .. tostring(ferr)
return
end
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
local code = socket.skip(1, http.request{
url = url,
headers = { ["Accept-Encoding"] = "identity" },
sink = ltn12.sink.file(f),
})
socketutil:reset_timeout()
if code == 200 then
result = "ok"
elseif code == 404 then
result = "404"
else
result = "error:http:" .. tostring(code)
end
end)
if not ok then
result = "error:exception:" .. tostring(err)
end
pcall(FFIUtil.writeToFD, child_write_fd, result or "error:unknown", true)
-- Hard exit, bypassing libc atexit handlers (rule 2).
local ffi = require("ffi")
pcall(ffi.cdef, "void _exit(int status);")
ffi.C._exit(0)
end,
true) -- with_pipe = true
if not pid then
if cb then cb(false, "fork failed") end
return
end
local poll_interval = 0.3
local poll
poll = function()
if FFIUtil.isSubProcessDone(pid) then
local result = FFIUtil.readAllFromFD(parent_read_fd) or ""
if result == "ok" then
if cb then cb(true, dst) end
elseif result == "404" then
os.remove(dst)
if cb then cb(false, "no-cover", 404) end
else
os.remove(dst)
if cb then cb(false, result, nil) end
end
else
UIManager:scheduleIn(poll_interval, poll)
end
end
UIManager:scheduleIn(poll_interval, poll)
end)
end)
end
-- ---------------------------------------------------------------------------
-- extractLocalCover(file_path, dst_png) → true on success
-- ---------------------------------------------------------------------------
-- Render the book's embedded cover to dst_png as PNG via coverbrowser's
-- BookInfo:getCoverImage, which opens the document, honors any custom cover
-- the user set in KOReader, and returns a native-resolution blitbuffer.
-- Passing a nil document + the file path makes BookInfo open + close the
-- document itself (same call form calibre.koplugin uses). Live-KOReader only
-- (FileManagerBookInfo + blitbuffer); the success/failure wiring is exercised
-- by a busted test that injects a fake BookInfo.
function M.extractLocalCover(file_path, dst_png)
if not file_path or not dst_png then return false end
local ok, FileManagerBookInfo = pcall(require, "apps/filemanager/filemanagerbookinfo")
if not ok or not FileManagerBookInfo then return false end
local got, cover_bb = pcall(FileManagerBookInfo.getCoverImage, FileManagerBookInfo, nil, file_path)
if not got or not cover_bb then return false end
local wrote = cover_bb:writeToFile(dst_png, "png")
if cover_bb.free then cover_bb:free() end
return wrote == true
end
-- ---------------------------------------------------------------------------
-- uploadBook(book, opts, cb) — push a local book file to Readest cloud.
-- ---------------------------------------------------------------------------
-- Two-step flow mirroring `apps/readest-app/src/libs/storage.ts:42-78`:
-- 1. POST /storage/upload {fileName, fileSize, bookHash} → server
-- validates quota, inserts a row in `files`, returns a presigned
-- PUT URL valid for 30 min.
-- 2. PUT raw bytes to that URL via socket.http (synchronous, mirrors
-- downloadBook for the same UX trade-off — UI freezes during the
-- upload but the dialog stays visible).
--
-- Cover.png handling: if a cover is already cached at <covers_dir>/<hash>.png
-- (from a prior cloud download) upload it as-is; otherwise extract the
-- embedded cover from the local file via extractLocalCover so books that
-- originated on this device still get a cover in the cloud (issue #4374).
-- Best-effort: books with no extractable cover skip the cover step silently
-- and the server tolerates a book with no cover row.
--
-- opts: { sync_auth, sync_path, settings, covers_dir = optional }
-- book: row with { hash, format, file_path, title, source_title }
-- cb: function(success, msg, status)
function M.uploadBook(book, opts, cb)
local logger = require("logger")
local lfs = require("libs/libkoreader-lfs")
local SyncAuth = opts.sync_auth
if not book or not book.hash or not book.format or not book.file_path then
if cb then cb(false, "missing book info") end
return
end
local ext = EXTS[book.format]
if not ext then
if cb then cb(false, "unsupported format") end
return
end
local attr = lfs.attributes(book.file_path)
if not attr or attr.mode ~= "file" then
if cb then cb(false, "local file missing") end
return
end
local fileSize = attr.size
-- Cloud-relative path: matches getRemoteBookFilename for S3 storage
-- (apps/readest-app/src/utils/book.ts:24). Server prepends "<user.id>/"
-- to form the final fileKey.
local bookFileName = string.format("%s/%s/%s.%s",
CLOUD_BOOKS_SUBDIR, book.hash, book.hash, ext)
local cover_path = opts.covers_dir
and (opts.covers_dir .. "/" .. book.hash .. ".png") or nil
local cover_attr = cover_path and lfs.attributes(cover_path) or nil
local has_cover = cover_attr and cover_attr.mode == "file"
-- No cached cloud cover (e.g. a book that originated on this device and
-- was never downloaded from the cloud): extract the embedded cover from
-- the local file so it still ships a cover.png. Cached under covers_dir so
-- the Library view reuses it just like a downloaded cover would.
if not has_cover and cover_path then
if not lfs.attributes(opts.covers_dir, "mode") then
lfs.mkdir(opts.covers_dir)
end
if M.extractLocalCover(book.file_path, cover_path) then
cover_attr = lfs.attributes(cover_path)
has_cover = cover_attr and cover_attr.mode == "file"
end
end
-- Synchronous PUT helper. Returns (ok, code, body_or_err) — body
-- captures the S3/R2 XML error response on failure, so the caller
-- can log something more useful than just "table: 0x...". Bug
-- before: I had `local _, code = socket.skip(1, http.request{...})`,
-- which assigned the headers table (the second value after skip) to
-- `code`, resulting in log lines like "code=table: 0x01156a2d48".
-- socket.skip(1, ...) drops the first return value of http.request,
-- so the first remaining return IS the HTTP status code.
local function put_bytes(url, src_path, size)
local socket = require("socket")
local http = require("socket.http")
local socketutil = require("socketutil")
local ltn12 = require("ltn12")
local f, ferr = io.open(src_path, "rb")
if not f then return false, nil, "open: " .. tostring(ferr) end
local body_chunks = {}
socketutil:set_timeout(socketutil.FILE_BLOCK_TIMEOUT, socketutil.FILE_TOTAL_TIMEOUT)
local code, headers, status_line = socket.skip(1, http.request{
url = url,
method = "PUT",
source = ltn12.source.file(f),
headers = { ["content-length"] = tostring(size) },
sink = ltn12.sink.table(body_chunks),
})
socketutil:reset_timeout()
local ok = (code == 200 or code == 204)
local body = table.concat(body_chunks)
if not ok then
logger.warn("ReadestLibrary uploadBook PUT non-2xx: code="
.. tostring(code) .. " status=" .. tostring(status_line)
.. " ctype=" .. tostring(headers and headers["content-type"])
.. " body_len=" .. #body
.. " body_head=" .. tostring(body:sub(1, 400)))
end
return ok, code, body
end
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok)
if not ok then
if cb then cb(false, "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
if cb then cb(false, "no sync client") end
return
end
-- Step 1: book file presigned URL
logger.info("ReadestLibrary uploadBook: requesting URL for "
.. bookFileName .. " (size=" .. fileSize .. ")")
client:getUploadUrl({
fileName = bookFileName,
fileSize = fileSize,
bookHash = book.hash,
}, function(s, body, status)
if not s or not body or not body.uploadUrl then
local msg
if status == 403 and body and body.error then
msg = body.error -- "Insufficient storage quota"
else
msg = "upload-url-failed"
end
logger.warn("ReadestLibrary uploadBook: getUploadUrl failed status="
.. tostring(status) .. " msg=" .. tostring(msg)
.. " body=" .. tostring(body and body.error or "<nil>"))
if cb then cb(false, msg, status) end
return
end
logger.info("ReadestLibrary uploadBook: presigned URL received"
.. " (host=" .. tostring(body.uploadUrl:match("^https?://([^/]+)") or "?")
.. ", quota_usage=" .. tostring(body.usage)
.. " quota=" .. tostring(body.quota) .. ")")
-- Step 2: PUT book bytes
local put_ok, put_code, put_body = put_bytes(body.uploadUrl, book.file_path, fileSize)
logger.info("ReadestLibrary uploadBook: book PUT code=" .. tostring(put_code)
.. " ok=" .. tostring(put_ok))
if not put_ok then
local err = "book upload failed"
if put_body and #put_body > 0 then
-- S3/R2 returns XML on error; surface the <Code>...</Code> tag
-- if present so the caller's toast can show something useful.
local s3code = put_body:match("<Code>(.-)</Code>")
if s3code then err = err .. " (" .. s3code .. ")" end
end
if cb then cb(false, err, put_code) end
return
end
-- Optional cover upload — best-effort.
if has_cover then
local coverFileName = string.format("%s/%s/cover.png",
CLOUD_BOOKS_SUBDIR, book.hash)
local cover_size = cover_attr.size
client:getUploadUrl({
fileName = coverFileName,
fileSize = cover_size,
bookHash = book.hash,
}, function(s2, b2, status2)
if s2 and b2 and b2.uploadUrl then
local c_ok, c_code = put_bytes(b2.uploadUrl, cover_path, cover_size)
logger.info("ReadestLibrary uploadBook: cover PUT code="
.. tostring(c_code) .. " ok=" .. tostring(c_ok))
else
logger.info("ReadestLibrary uploadBook: cover URL skipped status="
.. tostring(status2))
end
if cb then cb(true) end
end)
else
if cb then cb(true) end
end
end)
end)
end
-- ---------------------------------------------------------------------------
-- deleteCloudFiles(book, opts, cb) — discover the storage objects for a
-- book hash via /storage/list, then DELETE each one. Mirrors Readest's
-- cloudService.deleteBook flow at apps/readest-app/src/services/
-- cloudService.ts:43-54 + libs/storage.ts:180-195.
--
-- The DELETE endpoint requires the literal file_key (no extension
-- fallback like /storage/download has), so we MUST list first to learn
-- the actual filenames — they may differ between R2 ({title}.{ext}) and
-- S3 ({hash}.{ext}) deployments.
--
-- Tolerates per-file failures (matches the web client's try/catch
-- around each delete) and reports overall success when at least one
-- delete succeeded — so a missing cover doesn't fail the book delete.
--
-- opts: { sync_auth, sync_path, settings }
-- cb: function(success, msg, status)
-- ---------------------------------------------------------------------------
function M.deleteCloudFiles(book, opts, cb)
local logger = require("logger")
local SyncAuth = opts.sync_auth
if not book or not book.hash then
if cb then cb(false, "missing book") end
return
end
SyncAuth:withFreshToken(opts.settings, opts.sync_path, function(ok)
if not ok then
if cb then cb(false, "auth refresh failed") end
return
end
local client = SyncAuth:getReadestSyncClient(opts.settings, opts.sync_path)
if not client then
if cb then cb(false, "no sync client") end
return
end
logger.info("ReadestLibrary deleteCloudFiles: hash=" .. book.hash)
client:listFiles({ bookHash = book.hash }, function(success, body, status)
if not success then
logger.warn("ReadestLibrary deleteCloudFiles: listFiles failed status="
.. tostring(status))
if cb then cb(false, body and body.error or "list failed", status) end
return
end
local files = body and body.files or {}
if #files == 0 then
logger.info("ReadestLibrary deleteCloudFiles: no files for hash "
.. book.hash .. " — already gone")
if cb then cb(true, 0) end
return
end
-- Sequential delete (one at a time): the web client tolerates
-- per-file failures and we want to mirror that without hiding
-- partial-success cases. Fire DELETEs one after another via
-- callback chaining; track per-file outcomes.
local total = #files
local done, ok_count = 0, 0
local last_status
local function step(i)
if i > total then
logger.info("ReadestLibrary deleteCloudFiles: " .. ok_count
.. "/" .. total .. " deleted (last_status="
.. tostring(last_status) .. ")")
if cb then
cb(ok_count > 0, ok_count, last_status)
end
return
end
local fkey = files[i].file_key
logger.info("ReadestLibrary deleteCloudFiles: deleting " .. tostring(fkey))
client:deleteFile({ fileKey = fkey }, function(s, _b, st)
done = done + 1
if s then ok_count = ok_count + 1 end
last_status = st
if not s then
logger.warn("ReadestLibrary deleteCloudFiles: failed to delete "
.. tostring(fkey) .. " status=" .. tostring(st))
end
step(i + 1)
end)
end
step(1)
end)
end)
end
return M