Files
neon edc2a2ccfc feat(reader): make KOReader plugin sync non-blocking and resumable
Lifecycle handlers previously blocked on network work, so closing or suspending a book stalled the
device on slow or offline links. Persist snapshots first and drain them through a lifecycle outbox
with connected-state gates and phase-aware retries, so the UI stays responsive and pending work
survives restarts.

Move persistence behind a state manager with atomic flushes, and chunk sweeps, bulk downloads, and
annotation exchange through a background queue with checkpoints and transfer policies, so large
libraries make progress incrementally instead of restarting from scratch after an interruption.

Server side, add bulk manifest and page stats endpoints with batched queries and catalog query
filtering so the plugin can reconcile many books per round trip rather than one request per book.
2026-07-28 02:06:50 +00:00

122 lines
4.0 KiB
Lua

--[[--
Progress channel between a transfer subprocess and its parent.
A forked child cannot call back into KOReader's UI, so a background transfer
publishes bounded progress snapshots to a generation-scoped file instead. The
parent polls that file from short scheduled callbacks.
Each snapshot is one short line published with an atomic rename, so the parent
never reads a half-written value, malformed content decodes to nothing, and a
snapshot stamped with another generation is ignored rather than displayed.
]]
local MAGIC = "bookorbit-progress"
local VERSION = 1
local MAX_SNAPSHOT_BYTES = 128
local MIN_INTERVAL = 1
local MIN_BYTES = 128 * 1024
local TransferProgress = {}
TransferProgress.MAX_SNAPSHOT_BYTES = MAX_SNAPSHOT_BYTES
function TransferProgress.encode(snapshot)
snapshot = snapshot or {}
return string.format("%s %d %d %d %d %d\n",
MAGIC,
VERSION,
math.max(0, math.floor(tonumber(snapshot.generation) or 0)),
math.max(0, math.floor(tonumber(snapshot.received) or 0)),
math.max(0, math.floor(tonumber(snapshot.total) or 0)),
snapshot.done and 1 or 0)
end
function TransferProgress.decode(text)
if type(text) ~= "string" or text == "" or #text > MAX_SNAPSHOT_BYTES then return nil end
local magic, version, generation, received, total, done =
text:match("^(%S+) (%d+) (%d+) (%d+) (%d+) (%d+)%s*$")
if magic ~= MAGIC or tonumber(version) ~= VERSION then return nil end
return {
generation = tonumber(generation),
received = tonumber(received),
total = tonumber(total),
done = done == "1",
}
end
local function defaultWrite(content, path)
local handle, err = io.open(path, "w")
if not handle then return nil, tostring(err or "open_failed") end
handle:write(content)
handle:close()
return true
end
local function defaultRead(path)
local handle = io.open(path, "r")
if not handle then return nil end
local content = handle:read(MAX_SNAPSHOT_BYTES + 1)
handle:close()
return content
end
-- Returns writer(received, done). Snapshots are throttled so a fast transfer
-- cannot turn into a write-per-chunk storm on slow device storage; completion
-- always publishes.
function TransferProgress.writer(path, opts)
opts = opts or {}
local generation = opts.generation or 0
local total = opts.total or 0
local now = opts.now or os.time
local write = opts.write or defaultWrite
local rename = opts.rename or os.rename
local remove = opts.remove or os.remove
local min_interval = opts.min_interval or MIN_INTERVAL
local min_bytes = opts.min_bytes or MIN_BYTES
local temporary = path .. ".tmp"
local last_at, last_bytes
return function(received, done)
received = received or 0
local at = now()
if not done and last_at ~= nil
and received - last_bytes < min_bytes
and at - last_at < min_interval then
return false
end
last_at, last_bytes = at, received
local content = TransferProgress.encode({
generation = generation,
received = received,
total = total,
done = done,
})
remove(temporary)
if not write(content, temporary) then return false end
if not rename(temporary, path) then
remove(temporary)
return false
end
return true
end
end
-- Returns the current snapshot, or nil when the file is absent, malformed or
-- stamped with a generation the caller no longer cares about.
function TransferProgress.read(path, opts)
opts = opts or {}
local snapshot = TransferProgress.decode((opts.read or defaultRead)(path))
if not snapshot then return nil end
if opts.generation ~= nil and snapshot.generation ~= opts.generation then return nil end
return snapshot
end
function TransferProgress.cleanup(path, opts)
opts = opts or {}
local remove = opts.remove or os.remove
remove(path .. ".tmp")
remove(path)
end
return TransferProgress