feat: add read-aloud and media playback support

Add EPUB narration, shared media controls, and native device session integration.

Apply the generated migrations and update all first-party clients alongside the server.

BREAKING CHANGE: introduce incompatible database and API contracts for media playback
This commit is contained in:
neon
2026-09-19 02:02:24 +00:00
parent c292d6ccd2
commit e0d3d42150
844 changed files with 151839 additions and 3415 deletions
@@ -541,6 +541,7 @@ function BookOrbitApi:matchCheck(hashes, candidates)
authors = boundedUtf8(cand.authors, MATCH_AUTHORS_MAX_BYTES),
lastOpen = nonNegativeInteger(cand.last_open),
source = validMatchSource(cand.source),
bookFileId = nonNegativeInteger(cand.book_file_id),
metadataAmbiguous = optionalBoolean(cand.metadata_ambiguous),
})
end
@@ -1208,7 +1208,7 @@ function CatalogBulkDownload.install(Catalog)
return true
end
self:bulkQueueMatch(ctx, { digest = digest, file = local_path })
self:bulkQueueMatch(ctx, { digest = digest, file = local_path, bookFileId = file.id })
return true
end
@@ -1226,7 +1226,11 @@ function CatalogBulkDownload.install(Catalog)
logger.warn("BookOrbit: downloaded file partial MD5 failed", entry.file)
return
end
table.insert(ctx.pending_matches, { digest = digest, file = entry.file })
table.insert(ctx.pending_matches, {
digest = digest,
file = entry.file,
bookFileId = entry.bookFileId,
})
end
-- Applies queued links in one state flush and resolves the remainder with a
@@ -1245,7 +1249,10 @@ function CatalogBulkDownload.install(Catalog)
local hashes, candidates = {}, {}
for _, entry in ipairs(matches) do
table.insert(hashes, entry.digest)
candidates[entry.digest] = { source = "file" }
candidates[entry.digest] = {
source = "file",
book_file_id = entry.bookFileId,
}
end
local body, err = self.client:matchCheck(hashes, candidates)
if not body then
@@ -1369,7 +1376,7 @@ function CatalogBulkDownload.install(Catalog)
local lines = {
ctx.cancelled and _("Bulk download stopped.") or _("Bulk download complete."),
T(_("Downloaded: %1"), counts.downloaded),
T(_("Linked: %1"), counts.linked),
T(_("Linked for sync: %1"), counts.linked),
T(_("Skipped on device: %1"), counts.skipped_on_device),
T(_("Existing file (Not in BookOrbit): %1"), counts.skipped_existing),
T(_("Renamed path conflicts: %1"), counts.path_conflicts or 0),
@@ -379,7 +379,7 @@ function CatalogDownload.install(Catalog)
return
end
local linked = self:linkDownloadedFile(local_path)
local linked = self:linkDownloadedFile(local_path, file)
if linked then
self:refreshOnDevice()
if self.markStackDirty then self:markStackDirty() end
@@ -395,14 +395,18 @@ function CatalogDownload.install(Catalog)
end)
end
function Catalog:linkDownloadedFile(local_path)
function Catalog:linkDownloadedFile(local_path, file)
local ok, digest = pcall(util.partialMD5, local_path)
if not ok or not digest then
logger.warn("BookOrbit: downloaded file partial MD5 failed", local_path)
return false
end
local body, err = self.client:matchCheck({ digest }, { [digest] = { source = "file" } })
local candidate = { source = "file" }
if file and file.id then
candidate.book_file_id = file.id
end
local body, err = self.client:matchCheck({ digest }, { [digest] = candidate })
if not body then
logger.warn("BookOrbit: downloaded file match-check failed", err)
return false
@@ -429,7 +433,7 @@ function CatalogDownload.install(Catalog)
end
function Catalog:showDownloadedDialog(local_path, linked)
local message = linked and _("File saved and linked to BookOrbit sync:\n%1\n\nOpen now?")
local message = linked and _("File saved and linked for BookOrbit progress sync:\n%1\n\nOpen now?")
or _("File saved:\n%1\n\nOpen now?")
UIManager:nextTick(function()
UIManager:show(ConfirmBox:new{