fix(koplugin): upload local book cover so synced books show a cover (#4374) (#4385)

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:
Huang Xin
2026-05-31 23:36:23 +08:00
committed by GitHub
parent e8675fb7eb
commit a92fe0ce2e
2 changed files with 116 additions and 4 deletions
+41 -4
View File
@@ -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.
--