diff --git a/apps/readest.koplugin/library/syncbooks.lua b/apps/readest.koplugin/library/syncbooks.lua index 0e93e258..3c01d2d4 100644 --- a/apps/readest.koplugin/library/syncbooks.lua +++ b/apps/readest.koplugin/library/syncbooks.lua @@ -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 /.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 /.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 diff --git a/apps/readest.koplugin/spec/library/syncbooks_spec.lua b/apps/readest.koplugin/spec/library/syncbooks_spec.lua index dccf2885..b4139b08 100644 --- a/apps/readest.koplugin/spec/library/syncbooks_spec.lua +++ b/apps/readest.koplugin/spec/library/syncbooks_spec.lua @@ -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. --