mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
edc2a2ccfc
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.
134 lines
4.4 KiB
Lua
134 lines
4.4 KiB
Lua
--[[--
|
|
Resumable checkpoint for a bulk download run.
|
|
|
|
One compact record, published with an atomic rename so an interruption can
|
|
never leave a half-written checkpoint. It holds the source identity, the
|
|
manifest snapshot it was enumerated against, the last fully committed cursor
|
|
and bounded per-page completion and failure summaries. It deliberately does not
|
|
retain manifest rows: a resumed run re-enumerates from the cursor and validates
|
|
already-published files instead.
|
|
]]
|
|
|
|
local DataStorage = require("datastorage")
|
|
local dump = require("dump")
|
|
local lfs = require("libs/libkoreader-lfs")
|
|
local util = require("util")
|
|
|
|
local CHECKPOINT_DIR = "bookorbit-bulk-download"
|
|
local CHECKPOINT_FILE = "checkpoint.lua"
|
|
local VERSION = 1
|
|
local MAX_COMPLETED = 250
|
|
local MAX_FAILURES = 50
|
|
|
|
local Checkpoint = {}
|
|
Checkpoint.__index = Checkpoint
|
|
|
|
Checkpoint.VERSION = VERSION
|
|
Checkpoint.MAX_COMPLETED = MAX_COMPLETED
|
|
Checkpoint.MAX_FAILURES = MAX_FAILURES
|
|
|
|
local function safeLoad(path)
|
|
local chunk = loadfile(path)
|
|
if not chunk then return nil end
|
|
setfenv(chunk, {})
|
|
local ok, value = pcall(chunk)
|
|
if not ok or type(value) ~= "table" then return nil end
|
|
return value
|
|
end
|
|
|
|
function Checkpoint.open(opts)
|
|
opts = opts or {}
|
|
local settings_dir = opts.settings_dir or DataStorage:getSettingsDir()
|
|
local dir = settings_dir .. "/" .. CHECKPOINT_DIR
|
|
return setmetatable({
|
|
dir = dir,
|
|
path = dir .. "/" .. CHECKPOINT_FILE,
|
|
now = opts.now or os.time,
|
|
rename = opts.rename or os.rename,
|
|
remove = opts.remove or os.remove,
|
|
write_file = opts.write_file or function(content, path)
|
|
return util.writeToFile(content, path, true, false, true)
|
|
end,
|
|
make_path = opts.make_path or util.makePath,
|
|
attributes = opts.attributes or lfs.attributes,
|
|
load_record = opts.load_record or safeLoad,
|
|
serialize = opts.serialize or function(record)
|
|
return "return " .. dump(record) .. "\n"
|
|
end,
|
|
}, Checkpoint)
|
|
end
|
|
|
|
function Checkpoint:ensureDirectory()
|
|
if self.attributes(self.dir, "mode") == "directory" then return true end
|
|
self.make_path(self.dir)
|
|
return self.attributes(self.dir, "mode") == "directory"
|
|
end
|
|
|
|
function Checkpoint:load()
|
|
local record = self.load_record(self.path)
|
|
if type(record) ~= "table" or record.version ~= VERSION then return nil end
|
|
if type(record.source_key) ~= "string" or record.source_key == "" then return nil end
|
|
record.completed = type(record.completed) == "table" and record.completed or {}
|
|
record.counts = type(record.counts) == "table" and record.counts or {}
|
|
record.failures = type(record.failures) == "table" and record.failures or {}
|
|
return record
|
|
end
|
|
|
|
local function boundedCompleted(completed)
|
|
local bounded, kept = {}, 0
|
|
for book_id, path in pairs(completed or {}) do
|
|
if kept >= MAX_COMPLETED then break end
|
|
bounded[book_id] = path
|
|
kept = kept + 1
|
|
end
|
|
return bounded
|
|
end
|
|
|
|
local function boundedFailures(failures)
|
|
local bounded = {}
|
|
for index, failure in ipairs(failures or {}) do
|
|
if index > MAX_FAILURES then break end
|
|
table.insert(bounded, { id = failure.id, title = failure.title })
|
|
end
|
|
return bounded
|
|
end
|
|
|
|
function Checkpoint:save(record)
|
|
if not self:ensureDirectory() then return nil, "directory_failed" end
|
|
local stored = {
|
|
version = VERSION,
|
|
source_key = record.source_key,
|
|
label = record.label,
|
|
manifest_version = record.manifest_version,
|
|
cursor = record.cursor,
|
|
chunk_index = record.chunk_index,
|
|
page_number = record.page_number,
|
|
processed = record.processed,
|
|
completed = boundedCompleted(record.completed),
|
|
counts = record.counts,
|
|
failures = boundedFailures(record.failures),
|
|
updated_at = self.now(),
|
|
}
|
|
|
|
local temporary = self.path .. ".tmp"
|
|
self.remove(temporary)
|
|
local written, write_err = self.write_file(self.serialize(stored), temporary)
|
|
if not written then
|
|
self.remove(temporary)
|
|
return nil, write_err or "write_failed"
|
|
end
|
|
local renamed, rename_err = self.rename(temporary, self.path)
|
|
if not renamed then
|
|
self.remove(temporary)
|
|
return nil, rename_err or "rename_failed"
|
|
end
|
|
return stored
|
|
end
|
|
|
|
function Checkpoint:clear()
|
|
self.remove(self.path .. ".tmp")
|
|
self.remove(self.path)
|
|
end
|
|
|
|
return Checkpoint
|