feat(books): add read-aloud delivery policies and preserve sync

Add original and text-only EPUB delivery choices across Kobo, KOReader,
OPDS, web downloads and email while keeping source files unchanged.
Persist server defaults, device overrides and captured artifact identities
so retries and guided replacement retain the selected rendition.

Preserve saved progress across first opens, replacement resets and stale
updates, and project annotations against the copy delivered to each device.
Handle XML resource references, large inodes, retry backoff and background
worker state correctly. Consolidate the schema changes into one migration
and retain released-client request and response compatibility.

Verify with the full server and client checks, delivery and sync HTTP suites,
independent code reviews, and live KOReader download/settings/reader proof.

Fixes #1496
Closes #1603
Closes #1714
Fixes #1723
Ref #1633
Ref #1508
Ref #1621
Ref #1136
This commit is contained in:
neon
2026-10-11 03:11:27 +00:00
parent 7bb51a7a0b
commit cc50b55eeb
149 changed files with 30116 additions and 675 deletions
@@ -500,13 +500,15 @@ function BookOrbitApi:downloadBlocking(path, local_path, opts)
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 }
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
@@ -715,12 +717,24 @@ function BookOrbitApi:catalogManifest(params)
return self:request("GET", self:query("/koreader/plugin/catalog/manifest", params))
end
function BookOrbitApi:catalogBook(book_id)
return self:request("GET", self:query("/koreader/plugin/catalog/books/" .. tostring(book_id), { deviceId = self.device_id }))
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)
return self:download("/koreader/plugin/catalog/files/" .. tostring(file_id) .. "/download", 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)
@@ -16,7 +16,7 @@ local util = require("util")
local CHECKPOINT_DIR = "bookorbit-bulk-download"
local CHECKPOINT_FILE = "checkpoint.lua"
local VERSION = 1
local VERSION = 2
local MAX_COMPLETED = 250
local MAX_FAILURES = 50
@@ -439,9 +439,7 @@ function BookOrbitCatalog:refreshCurrent()
elseif context.kind == "detail" and context.detail then
self:evictCachedCovers(self:detailThumbnailBooks(context.detail))
self:runConnected(function()
local detail, err = self:fetch(_("Refreshing..."), function()
return self.client:catalogBook(context.detail.id)
end)
local detail, err = self:fetchDeliveryBook(context.detail.id, nil, _("Refreshing..."))
if not detail then
local cached = self:cachedBookDetail(context.detail.id)
if cached and err ~= "cancelled" then
@@ -617,6 +615,14 @@ function BookOrbitCatalog:dashboardResumePath(book)
end
function BookOrbitCatalog:onDeviceFilePath(file)
if file and file.selection then
local Delivery = require("bookorbit_delivery")
local variants = self.settings.catalog_downloaded_variants or {}
local marker = variants[Delivery.key(self.client) .. "|" .. tostring(file.id) .. "|" .. file.downloadVariant]
if type(marker) ~= "table" or marker.selection ~= file.selection then return nil end
local path = marker.path
return path and lfs.attributes(path) and path or nil
end
return file ~= nil and file.id ~= nil and self.on_device_files and self.on_device_files[file.id] or nil
end
@@ -188,6 +188,8 @@ local function chunkIds(ids, size)
return chunks
end
local Delivery = require("bookorbit_delivery")
local CatalogBulkDownload = {}
function CatalogBulkDownload.install(Catalog)
@@ -406,13 +408,50 @@ function CatalogBulkDownload.install(Catalog)
UIManager:show(dialog)
end
function Catalog:showDeliveryPreferenceDialog()
self:runConnected(function()
local current, err = self:fetchDeliveryPreference()
if err then UIManager:show(InfoMessage:new{ text = err }); return end
local server_settings = self.client.delivery_settings
local selected = server_settings and server_settings.deviceOverride or current
local uses_default = server_settings and server_settings.deviceOverride == nil
local dialog
local function select(variant)
UIManager:close(dialog)
self:runConnected(function()
local resolved, save_err = self:fetchDeliveryPreference(variant, true)
if not resolved then UIManager:show(InfoMessage:new{ text = save_err }); return end
self:showBulkDownloadSettings()
end)
end
dialog = ButtonDialog:new{
title = _("Choose EPUB delivery for this device. On supported servers this is saved in BookOrbit Settings > KOReader. Existing copies and interrupted downloads remain unchanged."),
buttons = {
{{ text = _("Use BookOrbit default") .. (uses_default and " *" or ""), callback = function() select(nil) end }},
{{ text = _("Text-only EPUB") .. (not uses_default and selected == "audioless_epub" and " *" or ""), callback = function() select("audioless_epub") end }},
{{ text = _("Original EPUB with narration") .. (not uses_default and selected == "original" and " *" or ""), callback = function() select("original") end }},
},
}
UIManager:show(dialog)
end)
end
function Catalog:showBulkDownloadSettings()
local dialog
local preset = self:bulkFormatPreset()
local policy = self:bulkExistingPolicy()
dialog = ButtonDialog:new{
title = _("Bulk download settings"),
title = _("Download settings"),
buttons = {
{
{
text = _("Read-aloud EPUB delivery"),
callback = function()
UIManager:close(dialog)
self:showDeliveryPreferenceDialog()
end,
},
},
{
{
text = T(_("Folder: %1"), BD.dirpath(shortPath(self:getCurrentDownloadDir(), FOLDER_LABEL_LENGTH))),
@@ -495,8 +534,10 @@ function CatalogBulkDownload.install(Catalog)
function Catalog:bulkCountKnownSkips(books)
local counts = { on_device = 0, unsupported = 0 }
local capabilities = Capabilities.cached(self.client)
local legacy = capabilities and not capabilities.readAloudDeliveryVariants
for _, book in ipairs(books or {}) do
if self:isOnDevice(book) then
if legacy and self:isOnDevice(book) then
counts.on_device = counts.on_device + 1
elseif not self:bulkSupportedFormatHint(book) then
counts.unsupported = counts.unsupported + 1
@@ -514,6 +555,9 @@ function CatalogBulkDownload.install(Catalog)
T(_("Format: %1"), self:bulkFormatPreset().label),
T(_("Existing files: %1"), self:bulkExistingPolicy().label),
}
if self:bulkExistingPolicy().id == "overwrite" then
table.insert(lines, _("Replacing existing files can invalidate their reading positions and highlights. Previous files remain intact until each download completes."))
end
if source.known_skips then
if source.known_skips.on_device > 0 then
table.insert(lines, T(_("Already on device: %1"), source.known_skips.on_device))
@@ -543,7 +587,9 @@ function CatalogBulkDownload.install(Catalog)
text = _("Download"),
callback = function()
UIManager:close(dialog)
self:startBulkSource(source)
self:runConnected(function()
self:startBulkSource(source)
end)
end,
},
},
@@ -592,10 +638,11 @@ function CatalogBulkDownload.install(Catalog)
end
function Catalog:bulkSourceKey(source)
local prefix = source.account_key and (source.account_key .. "|" .. tostring(source.variant) .. "|") or ""
if source.kind == "ids" then
return "ids|" .. idListKey(source.ids)
return prefix .. "ids|" .. idListKey(source.ids)
end
return "filter|" .. sortedParamKey(source.params or {})
return prefix .. "filter|" .. sortedParamKey(source.params or {})
end
function Catalog:startBulkSource(source)
@@ -604,6 +651,18 @@ function CatalogBulkDownload.install(Catalog)
return
end
source.account_key = Delivery.key(self.client)
local checkpoint = self:bulkOpenCheckpoint():load()
local pinned_variant
if checkpoint then
for _, candidate in ipairs({ "original", "audioless_epub" }) do
source.variant = candidate
if checkpoint.source_key == self:bulkSourceKey(source) then pinned_variant = candidate; break end
end
end
local variant, variant_err = self:fetchDeliveryPreference(pinned_variant)
if variant_err then UIManager:show(InfoMessage:new{ text = variant_err }); return end
source.variant = variant
local ctx = {
source = source,
source_key = self:bulkSourceKey(source),
@@ -774,6 +833,7 @@ function CatalogBulkDownload.install(Catalog)
params.size = PAGE_SIZE
params.cursor = position.cursor
params.variant = ctx.source.variant
local body, err = self.client:catalogManifest(params)
if not body then
-- A server that answers the route with "not found" is a confirmed
@@ -1037,14 +1097,19 @@ function CatalogBulkDownload.install(Catalog)
-- Restart validation: a destination this run already published is accepted
-- only when its size and content hash still match the manifest.
function Catalog:bulkValidatePublished(ctx, path, file, book_id)
function Catalog:bulkValidatePublished(ctx, path, file, book_id, recorded)
if not path then return false end
local size = lfs.attributes(path, "size")
if not size then return false end
if file.sizeBytes and file.sizeBytes > 0 and size ~= file.sizeBytes then return false end
if not file.fileHash then return false end
local expected_size, expected_hash = file.sizeBytes, file.fileHash
if type(recorded) == "table" then
if recorded.selection ~= file.selection or recorded.file_id ~= file.id then return false end
expected_size, expected_hash = recorded.size, recorded.hash
end
if expected_size and expected_size > 0 and size ~= expected_size then return false end
if not expected_hash then return false end
local hashed, digest = pcall(util.partialMD5, path)
if not hashed or digest ~= file.fileHash then return false end
if not hashed or digest ~= expected_hash then return false end
self:bulkQueueLink(ctx, {
digest = digest,
bookId = book_id,
@@ -1057,7 +1122,7 @@ function CatalogBulkDownload.install(Catalog)
function Catalog:bulkProcessBook(ctx, book)
self:bulkShowStatus(ctx, _("Checking book..."), book, ctx.index % 5 == 0)
if self:isOnDevice(book) then
if not (ctx.source and ctx.source.variant) and self:isOnDevice(book) then
ctx.counts.skipped_on_device = ctx.counts.skipped_on_device + 1
self:bulkShowStatus(ctx, _("Already on device - skipping"), book, ctx.index % 5 == 0)
return
@@ -1072,7 +1137,7 @@ function CatalogBulkDownload.install(Catalog)
if not book.files then
-- Only reached on a server without the manifest capability.
self:bulkShowStatus(ctx, _("Loading book details..."), book, true)
local body, err = self.client:catalogBook(book.id)
local body, err = self.client:catalogBook(book.id, ctx.source.variant)
if not body then
logger.warn("BookOrbit: bulk detail fetch failed", book.id, err)
self:bulkRecordFailure(ctx, book)
@@ -1080,14 +1145,19 @@ function CatalogBulkDownload.install(Catalog)
return
end
detail = body
if self:isOnDevice(detail) then
if not (ctx.source and ctx.source.variant) and self:isOnDevice(detail) then
ctx.counts.skipped_on_device = ctx.counts.skipped_on_device + 1
self:bulkShowStatus(ctx, _("Already on device - skipping"), detail, true)
return
end
end
local file = self:bulkChooseFile(detail)
local file = self:bulkChooseFile(detail, ctx.resumed)
if not file and #self:supportedFiles(detail) > 0 then
ctx.counts.skipped_on_device = ctx.counts.skipped_on_device + 1
self:bulkShowStatus(ctx, _("Already on device - skipping"), detail, true)
return
end
if not file then
ctx.counts.skipped_unsupported = ctx.counts.skipped_unsupported + 1
self:bulkShowStatus(ctx, _("No supported format - skipping"), detail, true)
@@ -1099,11 +1169,12 @@ function CatalogBulkDownload.install(Catalog)
if ctx.resumed then
local recorded = ctx.resumed_completed and ctx.resumed_completed[tostring(book_id)]
for _, candidate in ipairs({ recorded, local_path }) do
if self:bulkValidatePublished(ctx, candidate, file, book_id) then
local recorded_path = type(recorded) == "table" and recorded.path or recorded
for candidate_index, candidate in ipairs({ recorded_path or local_path, local_path }) do
if self:bulkValidatePublished(ctx, candidate, file, book_id, candidate == recorded_path and recorded or nil) then
ctx.counts.skipped_present = ctx.counts.skipped_present + 1
ctx.destination_paths[pathKey(candidate)] = { book_id = book_id, file_id = file.id }
ctx.completed[tostring(book_id)] = candidate
ctx.completed[tostring(book_id)] = recorded or candidate
self:bulkShowStatus(ctx, _("Already downloaded - skipping"), detail, true)
return
end
@@ -1126,11 +1197,19 @@ function CatalogBulkDownload.install(Catalog)
end
ctx.counts.downloaded = ctx.counts.downloaded + 1
ctx.destination_paths[pathKey(local_path)] = { book_id = book_id, file_id = file.id }
ctx.completed[tostring(book_id)] = local_path
if file.selection then
local hashed, digest = pcall(util.partialMD5, local_path)
ctx.completed[tostring(book_id)] = {
path = local_path, selection = file.selection, file_id = file.id,
size = lfs.attributes(local_path, "size"), hash = hashed and digest or nil,
}
else
ctx.completed[tostring(book_id)] = local_path
end
self:bulkShowStatus(ctx, _("Download complete"), detail, true)
end
function Catalog:bulkChooseFile(detail)
function Catalog:bulkChooseFile(detail, validate_present)
local files = self:supportedFiles(detail)
if #files == 0 then return nil end
@@ -1138,13 +1217,13 @@ function CatalogBulkDownload.install(Catalog)
local wanted = string.lower(format)
for _, file in ipairs(files) do
local file_format = string.lower(file.format or "")
if file_format == wanted and not self:onDeviceFilePath(file) then
if file_format == wanted and (validate_present or not self:onDeviceFilePath(file)) then
return file
end
end
end
for _, file in ipairs(files) do
if not self:onDeviceFilePath(file) then
if validate_present or not self:onDeviceFilePath(file) then
return file
end
end
@@ -1181,12 +1260,15 @@ function CatalogBulkDownload.install(Catalog)
destination = local_path,
generation = generation,
expected_bytes = total,
expected_hash = file.fileHash,
hash = "partial_md5",
on_progress = onProgress,
is_current = function()
return ctx == self.bulk_ctx and not ctx.cancel_requested
end,
perform = function(download_opts)
download_opts.variant = file.selection and (file.requestedVariant or file.downloadVariant) or nil
download_opts.selection = file.selection
return self.client:downloadCatalogFile(file.id, local_path, download_opts)
end,
}
@@ -1195,6 +1277,12 @@ function CatalogBulkDownload.install(Catalog)
return false
end
if file.selection then
local variants = self.settings.catalog_downloaded_variants or {}
variants[Delivery.key(self.client) .. "|" .. tostring(file.id) .. "|" .. file.downloadVariant] = { path = local_path, selection = file.selection }
self.settings.catalog_downloaded_variants = variants
ctx.delivery_markers_dirty = true
end
local digest = result and result.hash
if digest and file.fileHash and digest == file.fileHash then
-- The manifest hash is the digest local match state keys on, so a
@@ -1237,8 +1325,12 @@ function CatalogBulkDownload.install(Catalog)
-- single bounded match check, instead of one request and one flush per file.
function Catalog:bulkFlushLinks(ctx, force)
local pending = #ctx.pending_links + #ctx.pending_matches
if pending == 0 then return end
if not force and pending < LINK_BATCH_SIZE then return end
if ctx.delivery_markers_dirty then
self:persistSetting("catalog_downloaded_variants", self.settings.catalog_downloaded_variants)
ctx.delivery_markers_dirty = false
end
if pending == 0 then return end
local links = ctx.pending_links
local matches = ctx.pending_matches
@@ -254,6 +254,8 @@ function DetailTapLineWidget:onTapSelect()
return true
end
local Delivery = require("bookorbit_delivery")
local CatalogDetail = {}
-- A trailing ".0" is noise on a series index: #3, not #3.0.
@@ -423,7 +425,7 @@ function CatalogDetail:detailCache()
end
function CatalogDetail:cachedBookDetail(book_id)
local entry = self:detailCache()[tostring(book_id)]
local entry = self:detailCache()[Delivery.key(self.client) .. "|" .. Delivery.preference(self.settings, self.client) .. "|" .. tostring(book_id)]
if type(entry) == "table" and type(entry.detail) == "table" then
return entry.detail
end
@@ -433,7 +435,7 @@ end
function CatalogDetail:cacheBookDetail(detail)
if type(detail) ~= "table" or detail.id == nil then return end
local cache = self:detailCache()
cache[tostring(detail.id)] = {
cache[Delivery.key(self.client) .. "|" .. Delivery.preference(self.settings, self.client) .. "|" .. tostring(detail.id)] = {
detail = detail,
cachedAt = os.time(),
}
@@ -689,9 +691,7 @@ function CatalogDetail:showBookActionSheetForEntry(item)
return
end
self:runConnected(function()
local detail, err = self:fetch(_("Loading book..."), function()
return self.client:catalogBook(book_id)
end)
local detail, err = self:fetchDeliveryBook(book_id)
if not detail then
if cached and err ~= "cancelled" then
self:showBookActionSheet(cached, {
@@ -819,9 +819,7 @@ function CatalogDetail:loadBookDetail(book_id)
return
end
self:runConnected(function()
local detail, err = self:fetch(_("Loading book..."), function()
return self.client:catalogBook(book_id)
end)
local detail, err = self:fetchDeliveryBook(book_id)
if not detail then
if cached and err ~= "cancelled" then
self:showBookDetail(cached, { stale = true })
@@ -22,6 +22,7 @@ local _ = require("gettext")
local BookOrbitStateManager = require("bookorbit_state_manager")
local CatalogUtil = require("bookorbit_catalog_util")
local Transfer = require("bookorbit_download_transfer")
local Delivery = require("bookorbit_delivery")
local formatBytes = CatalogUtil.formatBytes
local safeFilenameBase = CatalogUtil.safeFilenameBase
@@ -68,6 +69,47 @@ local function splitDownloadPreview(download_dir, relative_path, filetype)
end
function CatalogDownload.install(Catalog)
function Catalog:fetchDeliveryPreference(variant, update)
local message = update and _("Saving EPUB delivery preference...") or _("Loading EPUB delivery preference...")
local body, err = self:fetch(message, function()
local resolved, resolve_err
if update then resolved, resolve_err = Delivery.update(self.settings, self.client, variant)
else resolved, resolve_err = Delivery.resolve(self.settings, self.client, variant) end
if resolve_err then return nil, resolve_err end
-- Only returned values cross the fork; apply settings and caches in the parent.
return {
variant = resolved,
delivery_settings = self.client.delivery_settings,
profiles = self.settings.catalog_delivery_profiles,
server_profiles = self.settings.catalog_delivery_server_profiles,
}
end)
if not body then return nil, err end
self.client.delivery_settings = body.delivery_settings
if body.profiles then self:persistSetting("catalog_delivery_profiles", body.profiles) end
if body.server_profiles then self:persistSetting("catalog_delivery_server_profiles", body.server_profiles) end
return body.variant
end
function Catalog:fetchDeliveryBook(book_id, override, message)
local variant, err = self:fetchDeliveryPreference(override)
if err then return nil, err end
return self:fetch(message or _("Loading book..."), function()
return self.client:catalogBook(book_id, variant)
end)
end
function Catalog:changeDownloadVariant(detail, file, use_default)
local variant = file.downloadVariant == "original" and "audioless_epub" or "original"
if use_default then variant = nil end
self:runConnected(function()
local selected, err = self:fetchDeliveryBook(detail.id, variant, _("Loading EPUB variant..."))
if not selected then self:showRetry(err, function() self:changeDownloadVariant(detail, file, use_default) end); return end
for _, candidate in ipairs(selected.files or {}) do
if candidate.id == file.id then self:showDownloadDialog(selected, candidate); return end
end
end)
end
function Catalog:showFileChoices(detail)
local files = self:supportedFiles(detail)
@@ -219,7 +261,21 @@ function CatalogDownload.install(Catalog)
end,
},
},
{},
{
{
text = file.downloadVariant == "original" and _("Use text-only EPUB")
or (file.sourceMediaOverlayAvailable and _("Use original EPUB with narration") or _("Use original EPUB")),
enabled = filetype == "epub",
callback = function() UIManager:close(dialog); self:changeDownloadVariant(detail, file) end,
},
},
{
{
text = _("Use BookOrbit default"),
enabled = filetype == "epub",
callback = function() UIManager:close(dialog); self:changeDownloadVariant(detail, file, true) end,
},
},
{
{
text = _("Choose folder"),
@@ -281,7 +337,7 @@ function CatalogDownload.install(Catalog)
if lfs.attributes(local_path) then
UIManager:show(ConfirmBox:new{
text = T(_("The file %1 already exists. Do you want to overwrite it?"), BD.filepath(local_path)),
text = T(_("The file %1 already exists. Replacing it can invalidate its reading position and highlights. The previous file stays intact until the download finishes. Continue?"), BD.filepath(local_path)),
ok_text = _("Overwrite"),
ok_callback = download,
})
@@ -362,11 +418,15 @@ function CatalogDownload.install(Catalog)
destination = local_path,
generation = generation,
expected_bytes = total,
expected_hash = file.fileHash,
hash = "partial_md5",
on_progress = onProgress,
is_current = function()
return self.download_generation == generation
end,
perform = function(download_opts)
download_opts.variant = file.selection and (file.requestedVariant or file.downloadVariant) or nil
download_opts.selection = file.selection
return self.client:downloadCatalogFile(file.id, local_path, download_opts)
end,
}
@@ -379,6 +439,11 @@ function CatalogDownload.install(Catalog)
return
end
if file.selection then
local variants = self.settings.catalog_downloaded_variants or {}
variants[Delivery.key(self.client) .. "|" .. tostring(file.id) .. "|" .. file.downloadVariant] = { path = local_path, selection = file.selection }
self:persistSetting("catalog_downloaded_variants", variants)
end
local linked = self:linkDownloadedFile(local_path, file)
if linked then
self:refreshOnDevice()
@@ -0,0 +1,85 @@
local Capabilities = require("bookorbit_capabilities")
local _ = require("gettext")
local Delivery = {}
local function validVariant(value)
return value == "original" or value == "audioless_epub"
end
function Delivery.key(client)
return tostring(client.server_url) .. "|" .. tostring(client.username)
end
local function legacyPreference(settings, client)
return (settings.catalog_delivery_profiles or {})[Delivery.key(client)]
end
function Delivery.preference(settings, client)
local cached = client.delivery_settings
if cached and cached.key == Delivery.key(client) then return cached.readAloudDelivery end
local remote = (settings.catalog_delivery_server_profiles or {})[Delivery.key(client)]
if validVariant(remote) then return remote end
return legacyPreference(settings, client) == "original" and "original" or "audioless_epub"
end
local function remember(settings, client, body)
if type(body) ~= "table" or not validVariant(body.readAloudDelivery) then
return nil, _("The server returned an invalid EPUB delivery preference. Reconnect and retry.")
end
local profiles = settings.catalog_delivery_server_profiles or {}
profiles[Delivery.key(client)] = body.readAloudDelivery
settings.catalog_delivery_server_profiles = profiles
client.delivery_settings = {
key = Delivery.key(client), readAloudDelivery = body.readAloudDelivery,
credentialDefault = body.credentialDefault, deviceOverride = body.deviceOverride,
}
return body.readAloudDelivery
end
function Delivery.refresh(settings, client)
local body = client:getDeliverySettings()
if not body then return nil, _("Could not load the EPUB delivery default from BookOrbit. Reconnect and retry, or choose a variant for this download.") end
if type(body) ~= "table" or not validVariant(body.readAloudDelivery) then return remember(settings, client, body) end
if body.deviceConfigured == false then
local legacy = legacyPreference(settings, client)
body = client:setDeliverySettings(validVariant(legacy) and legacy or nil, true)
if not body then return nil, _("Could not migrate the EPUB delivery preference to BookOrbit. Reconnect and retry.") end
end
return remember(settings, client, body)
end
function Delivery.resolve(settings, client, override)
local supported = client.getPluginVersion and Capabilities.supports(client, "readAloudDeliveryVariants")
if supported then
if override then return override end
if Capabilities.supports(client, "readAloudDeliverySettings") then return Delivery.refresh(settings, client) end
return Delivery.preference(settings, client)
end
local requested = override or Delivery.preference(settings, client)
if requested == "original" or override then
return nil, _("This server cannot confirm support for the requested EPUB variant. Reconnect to a compatible server and retry.")
end
return nil
end
function Delivery.update(settings, client, variant)
if client.getPluginVersion and Capabilities.supports(client, "readAloudDeliverySettings") then
local body = client:setDeliverySettings(variant, false)
if not body then return nil, _("Could not save the EPUB delivery preference in BookOrbit. Reconnect and retry.") end
return remember(settings, client, body)
end
if not variant then return nil, _("This server does not support a server default. Update BookOrbit first.") end
local resolved, err = Delivery.resolve(settings, client, variant)
if err then return nil, err end
Delivery.set(settings, client, resolved)
return resolved
end
function Delivery.set(settings, client, variant)
local profiles = settings.catalog_delivery_profiles or {}
profiles[Delivery.key(client)] = variant
settings.catalog_delivery_profiles = profiles
return profiles
end
return Delivery
@@ -168,6 +168,13 @@ function Transfer.run(opts)
return nil, "unsafe_destination"
end
local expected_hash = opts.expected_hash or (type(result) == "table" and result.server_hash)
if expected_hash then
local digest = type(result) == "table" and result.hash
if not digest then local ok, hash = pcall(util.partialMD5, completed_path); digest = ok and hash or nil end
if digest ~= expected_hash then util.removeFile(completed_path); return nil, "hash_mismatch" end
end
local renamed, rename_err = os.rename(completed_path, opts.destination)
if not renamed then
util.removeFile(completed_path)
@@ -147,7 +147,8 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
return true
end
if body.device == Device.model and body.device_id == self.device_id then
if body.device == Device.model and body.device_id == self.device_id
and (self.last_page_turn_timestamp or 0) > 0 then
on_done(false)
return true
end
@@ -319,10 +320,10 @@ function ProgressSync:getProgress(ensure_networking, interactive)
return
end
if body.device == Device.model and body.device_id == self.device_id then
if interactive then
UIManager:show(InfoMessage:new{ text = _("Latest progress is coming from this device."), timeout = 3 })
end
-- A fresh rendition or sidecar can resume behind this device's last upload.
-- Explicit pulls and opens before any page turn must still recover that position.
if not interactive and body.device == Device.model and body.device_id == self.device_id
and (self.last_page_turn_timestamp or 0) > 0 then
return
end
@@ -33,7 +33,7 @@ end
function TransferPolicy.maxBytes(expected_bytes)
local size = tonumber(expected_bytes)
if not size or size <= 0 then return MAX_TRANSFER_BYTES end
return math.min(MAX_TRANSFER_BYTES, math.ceil(size * SIZE_MARGIN) + SIZE_SLACK_BYTES)
return math.min(8 * 1024 * 1024 * 1024, math.ceil(size * SIZE_MARGIN) + SIZE_SLACK_BYTES)
end
return TransferPolicy
+1 -1
View File
@@ -48,7 +48,7 @@ local BookOrbitProgressSync = require("bookorbit_progress_sync")
local BookOrbitSweep = require("bookorbit_sweep")
local BookOrbitUpdater = require("bookorbit_updater")
local PLUGIN_VERSION = "1.5.5"
local PLUGIN_VERSION = "1.7.0"
local SYNC_STRATEGY = {
PROMPT = 1,