uploadBook only attached a cover.png when one was already cached under readest_covers/<hash>.png from a prior cloud download. Books that originated locally in KOReader were never downloaded, so the cover step was silently skipped and they synced to Readest with no cover. Add extractLocalCover, which renders the book's embedded cover via coverbrowser's FileManagerBookInfo:getCoverImage(nil, file_path) and writes it as PNG. uploadBook now falls back to it when no cached cover exists, caching the result under covers_dir so the Library view reuses it like a downloaded cover. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -673,6 +673,27 @@ function M.downloadCover(book, opts, cb)
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- extractLocalCover(file_path, dst_png) → true on success
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Render the book's embedded cover to dst_png as PNG via coverbrowser's
|
||||
-- BookInfo:getCoverImage, which opens the document, honors any custom cover
|
||||
-- the user set in KOReader, and returns a native-resolution blitbuffer.
|
||||
-- Passing a nil document + the file path makes BookInfo open + close the
|
||||
-- document itself (same call form calibre.koplugin uses). Live-KOReader only
|
||||
-- (FileManagerBookInfo + blitbuffer); the success/failure wiring is exercised
|
||||
-- by a busted test that injects a fake BookInfo.
|
||||
function M.extractLocalCover(file_path, dst_png)
|
||||
if not file_path or not dst_png then return false end
|
||||
local ok, FileManagerBookInfo = pcall(require, "apps/filemanager/filemanagerbookinfo")
|
||||
if not ok or not FileManagerBookInfo then return false end
|
||||
local got, cover_bb = pcall(FileManagerBookInfo.getCoverImage, FileManagerBookInfo, nil, file_path)
|
||||
if not got or not cover_bb then return false end
|
||||
local wrote = cover_bb:writeToFile(dst_png, "png")
|
||||
if cover_bb.free then cover_bb:free() end
|
||||
return wrote == true
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- uploadBook(book, opts, cb) — push a local book file to Readest cloud.
|
||||
-- ---------------------------------------------------------------------------
|
||||
@@ -684,10 +705,12 @@ end
|
||||
-- downloadBook for the same UX trade-off — UI freezes during the
|
||||
-- upload but the dialog stays visible).
|
||||
--
|
||||
-- Cover.png handling is intentionally minimal in v1: if a cover is
|
||||
-- already cached at <covers_dir>/<hash>.png (from a prior cloud
|
||||
-- download), upload it too. Books without a cached cover skip the cover
|
||||
-- step silently — the server tolerates books with no cover row.
|
||||
-- Cover.png handling: if a cover is already cached at <covers_dir>/<hash>.png
|
||||
-- (from a prior cloud download) upload it as-is; otherwise extract the
|
||||
-- embedded cover from the local file via extractLocalCover so books that
|
||||
-- originated on this device still get a cover in the cloud (issue #4374).
|
||||
-- Best-effort: books with no extractable cover skip the cover step silently
|
||||
-- and the server tolerates a book with no cover row.
|
||||
--
|
||||
-- opts: { sync_auth, sync_path, settings, covers_dir = optional }
|
||||
-- book: row with { hash, format, file_path, title, source_title }
|
||||
@@ -723,6 +746,20 @@ function M.uploadBook(book, opts, cb)
|
||||
local cover_attr = cover_path and lfs.attributes(cover_path) or nil
|
||||
local has_cover = cover_attr and cover_attr.mode == "file"
|
||||
|
||||
-- No cached cloud cover (e.g. a book that originated on this device and
|
||||
-- was never downloaded from the cloud): extract the embedded cover from
|
||||
-- the local file so it still ships a cover.png. Cached under covers_dir so
|
||||
-- the Library view reuses it just like a downloaded cover would.
|
||||
if not has_cover and cover_path then
|
||||
if not lfs.attributes(opts.covers_dir, "mode") then
|
||||
lfs.mkdir(opts.covers_dir)
|
||||
end
|
||||
if M.extractLocalCover(book.file_path, cover_path) then
|
||||
cover_attr = lfs.attributes(cover_path)
|
||||
has_cover = cover_attr and cover_attr.mode == "file"
|
||||
end
|
||||
end
|
||||
|
||||
-- Synchronous PUT helper. Returns (ok, code, body_or_err) — body
|
||||
-- captures the S3/R2 XML error response on failure, so the caller
|
||||
-- can log something more useful than just "table: 0x...". Bug
|
||||
|
||||
@@ -251,6 +251,81 @@ describe("library.syncbooks", function()
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- extractLocalCover(file_path, dst_png) — render a book's embedded cover
|
||||
-- to dst_png as PNG via coverbrowser's BookInfo:getCoverImage so
|
||||
-- uploadBook can ship a cover for books that originated on this device
|
||||
-- (issue #4374), not just ones previously downloaded from the cloud.
|
||||
-- The blitbuffer/document work is live-KOReader-only, so we inject a fake
|
||||
-- BookInfo via package.loaded and assert the success/failure wiring.
|
||||
-- =====================================================================
|
||||
describe("extractLocalCover", function()
|
||||
local BI_KEY = "apps/filemanager/filemanagerbookinfo"
|
||||
local saved_bookinfo
|
||||
|
||||
before_each(function()
|
||||
saved_bookinfo = package.loaded[BI_KEY]
|
||||
end)
|
||||
after_each(function()
|
||||
package.loaded[BI_KEY] = saved_bookinfo
|
||||
end)
|
||||
|
||||
it("writes the cover as PNG and returns true when the book has a cover", function()
|
||||
local wrote, freed
|
||||
local fake_bb = {
|
||||
writeToFile = function(_self, path, fmt)
|
||||
wrote = { path = path, fmt = fmt }
|
||||
return true
|
||||
end,
|
||||
free = function() freed = true end,
|
||||
}
|
||||
package.loaded[BI_KEY] = {
|
||||
getCoverImage = function(_self, document, file)
|
||||
-- Called with a nil document + the book's path so BookInfo
|
||||
-- opens the file itself (matches calibre.koplugin's usage).
|
||||
assert.is_nil(document)
|
||||
assert.are.equal("/books/foo.epub", file)
|
||||
return fake_bb
|
||||
end,
|
||||
}
|
||||
|
||||
local ok = syncbooks.extractLocalCover("/books/foo.epub", "/cache/abc.png")
|
||||
|
||||
assert.is_true(ok)
|
||||
assert.are.equal("/cache/abc.png", wrote.path)
|
||||
assert.are.equal("png", wrote.fmt)
|
||||
assert.is_true(freed, "the cover blitbuffer must be freed")
|
||||
end)
|
||||
|
||||
it("returns false when the book has no extractable cover", function()
|
||||
package.loaded[BI_KEY] = {
|
||||
getCoverImage = function() return nil end,
|
||||
}
|
||||
assert.is_false(syncbooks.extractLocalCover("/books/foo.epub", "/cache/abc.png"))
|
||||
end)
|
||||
|
||||
it("returns false when the PNG write fails", function()
|
||||
package.loaded[BI_KEY] = {
|
||||
getCoverImage = function()
|
||||
return { writeToFile = function() return false end, free = function() end }
|
||||
end,
|
||||
}
|
||||
assert.is_false(syncbooks.extractLocalCover("/books/foo.epub", "/cache/abc.png"))
|
||||
end)
|
||||
|
||||
it("returns false when coverbrowser's BookInfo isn't available", function()
|
||||
-- Neither package.loaded nor package.path resolves the module in
|
||||
-- the test env, so require() errors and the pcall guard kicks in.
|
||||
package.loaded[BI_KEY] = nil
|
||||
assert.is_false(syncbooks.extractLocalCover("/books/foo.epub", "/cache/abc.png"))
|
||||
end)
|
||||
|
||||
it("returns false for missing arguments", function()
|
||||
assert.is_false(syncbooks.extractLocalCover(nil, "/cache/abc.png"))
|
||||
assert.is_false(syncbooks.extractLocalCover("/books/foo.epub", nil))
|
||||
end)
|
||||
end)
|
||||
|
||||
-- =====================================================================
|
||||
-- syncBooks(opts, mode, cb, before_push) — bidirectional orchestration.
|
||||
--
|
||||
|
||||
Reference in New Issue
Block a user