mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user