mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
4a4b62bbef
Recompute the open file digest and recover previously verified matches without guessing between duplicate files. Preserve stranded statistics across lifecycle retries and advertise plugin version 1.5.3 so installed devices can update. Fixes #1497
283 lines
11 KiB
Lua
283 lines
11 KiB
Lua
--[[--
|
|
Persistent local sync state for the BookOrbit plugin.
|
|
|
|
This LuaSettings file is the plugin's only local database. Matched books carry
|
|
full watermark records, unmatched hashes only a last-checked timestamp, so
|
|
thousands of unmatched books stay cheap. statistics.sqlite3 is never written.
|
|
]]
|
|
|
|
local DataStorage = require("datastorage")
|
|
local LuaSettings = require("luasettings")
|
|
local dump = require("dump")
|
|
local ffiutil = require("ffi/util")
|
|
local lfs = require("libs/libkoreader-lfs")
|
|
|
|
local STATE_FILE = "bookorbit_sync_state.lua"
|
|
|
|
local BookOrbitState = {}
|
|
BookOrbitState.__index = BookOrbitState
|
|
|
|
-- Conservative first value: a completely unchanged book still has to learn
|
|
-- once a day that its server-side file was deleted or re-imported. Tighten or
|
|
-- widen only against measured daily versus multi-day sync intervals.
|
|
BookOrbitState.MATCH_MAX_AGE = 24 * 3600
|
|
|
|
local function deepCopy(value)
|
|
if type(value) ~= "table" then return value end
|
|
local copy = {}
|
|
for key, item in pairs(value) do
|
|
copy[deepCopy(key)] = deepCopy(item)
|
|
end
|
|
return copy
|
|
end
|
|
|
|
function BookOrbitState.open()
|
|
local self = setmetatable({}, BookOrbitState)
|
|
self:reload()
|
|
return self
|
|
end
|
|
|
|
function BookOrbitState.snapshot(tables, flush_handler)
|
|
return setmetatable({
|
|
books = deepCopy(tables.books or {}),
|
|
unmatched = deepCopy(tables.unmatched or {}),
|
|
files = deepCopy(tables.files or {}),
|
|
global = deepCopy(tables.global or {}),
|
|
flush_handler = flush_handler,
|
|
}, BookOrbitState)
|
|
end
|
|
|
|
-- Re-reads the settings file into this instance. Holders keep a valid
|
|
-- reference, so a reload cannot strand a caller on an orphaned snapshot it
|
|
-- would later flush over newer state.
|
|
function BookOrbitState:reload()
|
|
local settings = LuaSettings:open(DataStorage:getSettingsDir() .. "/" .. STATE_FILE)
|
|
self.settings = settings
|
|
-- readSetting with a default persists the returned live table on flush.
|
|
self.books = settings:readSetting("books", {})
|
|
self.unmatched = settings:readSetting("unmatched", {})
|
|
self.files = settings:readSetting("files", {})
|
|
self.global = settings:readSetting("global", {})
|
|
return self
|
|
end
|
|
|
|
function BookOrbitState:getBook(md5)
|
|
return self.books[md5]
|
|
end
|
|
|
|
-- Maps BookOrbit bookId -> local file path and bookFileId -> local file path
|
|
-- for every matched book whose file is still present on disk. Both maps come
|
|
-- from one scan with one existence check per stored file; bookorbit_state_manager
|
|
-- caches the result so a catalog repaint never rescans the matched library.
|
|
function BookOrbitState:matchedFileMaps()
|
|
local by_book_id, by_file_id = {}, {}
|
|
for _, book in pairs(self.books) do
|
|
local file = book.file
|
|
if file and (book.bookId or book.fileId) and lfs.attributes(file, "mode") == "file" then
|
|
if book.bookId then by_book_id[book.bookId] = file end
|
|
if book.fileId then by_file_id[book.fileId] = file end
|
|
end
|
|
end
|
|
return by_book_id, by_file_id
|
|
end
|
|
|
|
-- Every match-producing path funnels through here (lifecycle drain, open-book
|
|
-- match, sweep and the bulk manifest link). A different server book or file is
|
|
-- a fresh sync target even when the local digest is unchanged, so only its
|
|
-- local path may survive that transition. Both ids are checked because the
|
|
-- cursors below are split across them: reading statistics hang off the file
|
|
-- row, while highlights, status, rating and review hang off the book row, and a
|
|
-- folder merge re-parents a file to another book without changing its id. The
|
|
-- token the match was verified against is stored alongside the time so one
|
|
-- library change does not force a request on every later book sync until the
|
|
-- user runs a full sweep.
|
|
function BookOrbitState:setMatched(md5, book_file_id, book_id, file, verified_version)
|
|
local previous = self.books[md5]
|
|
local identity_changed = previous ~= nil
|
|
and (previous.fileId ~= book_file_id or previous.bookId ~= book_id)
|
|
local book = identity_changed and { file = file or previous.file } or (previous or {})
|
|
book.fileId = book_file_id
|
|
book.bookId = book_id
|
|
book.file = file or book.file
|
|
book.statsWatermark = book.statsWatermark or 0
|
|
book.annWatermark = book.annWatermark or ""
|
|
book.annCount = book.annCount or 0
|
|
book.matchVerifiedAt = os.time()
|
|
book.matchVerifiedVersion = verified_version or (self.global and self.global.libraryVersion) or nil
|
|
self.books[md5] = book
|
|
self.unmatched[md5] = nil
|
|
end
|
|
|
|
-- Drops the freshness stamp so the next sync rechecks, used when the user
|
|
-- explicitly asks for a rematch.
|
|
function BookOrbitState.expireMatch(book)
|
|
if not book then return end
|
|
book.matchVerifiedAt = nil
|
|
book.matchVerifiedVersion = nil
|
|
end
|
|
|
|
-- A match may be trusted without a request only inside a bounded age and, once
|
|
-- a library change has been observed, only when this book was verified against
|
|
-- the currently known token. A missing stamp is expired, so state written
|
|
-- before this existed rechecks once rather than being trusted forever.
|
|
function BookOrbitState.isMatchFresh(book, global, now)
|
|
if not book then return false end
|
|
local verified_at = book.matchVerifiedAt
|
|
if type(verified_at) ~= "number" then return false end
|
|
now = now or os.time()
|
|
if verified_at > now or (now - verified_at) > BookOrbitState.MATCH_MAX_AGE then
|
|
return false
|
|
end
|
|
global = global or {}
|
|
if global.needsFullRecheck == true then
|
|
local token = global.libraryVersion
|
|
if token == nil or book.matchVerifiedVersion ~= token then return false end
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Single invalidation path for the library-version token. matchCheck and
|
|
-- sweepComplete call it `libraryVersion` and the bulk manifest calls it
|
|
-- `manifestVersion`, but both come from the same server-side computation, so
|
|
-- every carrier feeds this one function. Returns true when the token moved.
|
|
function BookOrbitState.applyLibraryVersion(state, version, now)
|
|
if type(version) ~= "string" or version == "" then return false end
|
|
local global = state.global
|
|
local known = global.libraryVersion
|
|
local changed = known ~= nil and version ~= known
|
|
if changed then global.needsFullRecheck = true end
|
|
global.libraryVersion = version
|
|
global.libraryVersionCheckedAt = now or os.time()
|
|
return changed
|
|
end
|
|
|
|
function BookOrbitState:setUnmatched(md5)
|
|
self.books[md5] = nil
|
|
self.unmatched[md5] = os.time()
|
|
end
|
|
|
|
function BookOrbitState:rememberFile(file, md5)
|
|
if file and md5 then
|
|
self.files[file] = md5
|
|
local book = self.books[md5]
|
|
if book and not book.file then
|
|
book.file = file
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Repairs a path whose cached digest no longer matches the file on disk. A
|
|
-- server identity is never copied between digests: only an identity already
|
|
-- verified for the recomputed digest may be used to recover an ambiguous
|
|
-- server match.
|
|
function BookOrbitState:repairFileIdentity(file, old_md5, new_md5)
|
|
if not file or not new_md5 then return nil end
|
|
self.files[file] = new_md5
|
|
|
|
if old_md5 and old_md5 ~= new_md5 then
|
|
local old_book = self.books[old_md5]
|
|
if old_book and old_book.file == file then
|
|
old_book.file = nil
|
|
BookOrbitState.expireMatch(old_book)
|
|
end
|
|
self.unmatched[new_md5] = nil
|
|
end
|
|
|
|
local new_book = self.books[new_md5]
|
|
if new_book then
|
|
new_book.file = file
|
|
if old_md5 ~= new_md5 then
|
|
BookOrbitState.expireMatch(new_book)
|
|
new_book.statsWatermark = 0
|
|
end
|
|
end
|
|
return new_book
|
|
end
|
|
|
|
function BookOrbitState:flush()
|
|
if self.flush_handler then
|
|
return self.flush_handler(self)
|
|
end
|
|
if self.settings.atomicFlush then
|
|
local ok, err = self.settings:atomicFlush()
|
|
if not ok then error(tostring(err or "state_flush_failed"), 0) end
|
|
else
|
|
local file = self.settings.file
|
|
local temp = file .. ".bookorbit.tmp"
|
|
local backup = file .. ".old"
|
|
os.remove(temp)
|
|
|
|
local serialized = "-- " .. temp .. "\nreturn "
|
|
.. dump(self.settings.data, nil, true) .. "\n"
|
|
local out, open_err = io.open(temp, "wb")
|
|
if not out then error(tostring(open_err or "state_temp_open_failed"), 0) end
|
|
local written, write_err = out:write(serialized)
|
|
if not written then
|
|
out:close()
|
|
os.remove(temp)
|
|
error(tostring(write_err or "state_temp_write_failed"), 0)
|
|
end
|
|
local flushed, flush_err = out:flush()
|
|
if not flushed then
|
|
out:close()
|
|
os.remove(temp)
|
|
error(tostring(flush_err or "state_temp_flush_failed"), 0)
|
|
end
|
|
local synced, sync_err = pcall(ffiutil.fsyncOpenedFile, out)
|
|
local closed, close_err = out:close()
|
|
if not synced or not closed then
|
|
os.remove(temp)
|
|
error(tostring(sync_err or close_err or "state_temp_sync_failed"), 0)
|
|
end
|
|
|
|
local had_current = lfs.attributes(file, "mode") == "file"
|
|
if had_current then
|
|
local backed_up, backup_err = os.rename(file, backup)
|
|
if not backed_up then
|
|
os.remove(temp)
|
|
error(tostring(backup_err or "state_backup_failed"), 0)
|
|
end
|
|
end
|
|
|
|
local published, publish_err = os.rename(temp, file)
|
|
if not published then
|
|
if had_current then os.rename(backup, file) end
|
|
os.remove(temp)
|
|
error(tostring(publish_err or "state_publish_failed"), 0)
|
|
end
|
|
pcall(ffiutil.fsyncDirectory, file)
|
|
end
|
|
-- bookorbit_state_manager installs this to publish a new generation, so
|
|
-- derived caches invalidate no matter which plugin path wrote.
|
|
if self.on_flush then self.on_flush(self) end
|
|
end
|
|
|
|
-- Shared ack-gated stats watermark advance used by the sweep and the per-book
|
|
-- sync. Returns true when a full batch went out and more events may remain.
|
|
function BookOrbitState.applyStatsAck(book, events, body, md5, batch_size, old_watermark)
|
|
local server_watermark = old_watermark
|
|
for _, result in ipairs(body.results or {}) do
|
|
if result.hash == md5 and result.watermark then
|
|
server_watermark = result.watermark
|
|
end
|
|
end
|
|
|
|
if #events == batch_size then
|
|
-- A full batch may have been cut inside a one-second group of events;
|
|
-- back off by one second so the remainder is fetched next round
|
|
-- (duplicates are server-side no-ops).
|
|
local max_start = events[#events].startTime
|
|
local next_watermark = max_start - 1
|
|
if next_watermark <= old_watermark then
|
|
next_watermark = server_watermark
|
|
end
|
|
book.statsWatermark = next_watermark
|
|
return true
|
|
end
|
|
|
|
book.statsWatermark = server_watermark
|
|
return false
|
|
end
|
|
|
|
return BookOrbitState
|