Files
readest/apps/readest.koplugin/library/bim_patch.lua
T
Huang Xin 15c0a7a2f2 fix(koplugin): render group cover previews in Library (#4064)
Group cells in the Readest Library view rendered as FakeCover even when
their child books had perfectly good cloud covers — the queue that
fetches <hash>.png covers was only primed for cloud-only book entries
on the visible page, so children of group entries were never requested.
A later partial composite (3/4 covers) was also written to a
content-fingerprinted disk cache and kept serving forever, since the
fingerprint stayed the same after the 4th cover landed.

Two fixes wired together:
- libraryitem.set_visible_hashes now expands visible group entries to
  include their first-N children's hashes, so trigger_download's
  visibility filter no longer rejects them.
- group_covers.child_cover_bb queues a cloud-cover download when the
  fallback path misses for a cloud-present book. Capped at 4 per group
  by the existing cells_for(shape) limit.

Disk caching of composites is dropped entirely; mosaics are recomposed
in memory each paint. New spec/library/group_covers_spec.lua locks the
contract for URI round-trip, child_cover_bb's missing-cover branches,
and libraryitem's group-children expansion.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:01:35 +02:00

217 lines
8.5 KiB
Lua

-- bim_patch.lua
-- Two global monkey-patches that make our cloud-only / group entries
-- coexist with KOReader's coverbrowser pipeline:
--
-- 1. BookInfoManager:getBookInfo — intercepts readest-cloud:// and
-- readest-group:// URIs. Without this, MosaicMenuItem's
-- "info incomplete → schedule background extraction" path fires;
-- BIM forks a subprocess that crashes at bookinfomanager.lua:492
-- trying to lfs.attributes the synthetic URI.
--
-- 2. ListMenuItem:update + paintTo — list-mode group rows use a
-- custom widget tree (4-cell cover strip), and book rows get a
-- cloud-up/cloud-down icon overlay below the format text.
--
-- ListMenuItem is `local` to coverbrowser/listmenu.lua, so we reach
-- it via debug.getupvalue on the exported _updateItemsBuildUI mixin.
local logger = require("logger")
local cloud_covers = require("library.cloud_covers")
local group_covers = require("library.group_covers")
local cloud_icons = require("library.cloud_icons")
local list_strip = require("library.list_strip")
local M = {}
local _bim_patched = false
local _list_item_patched = false
local _orig_get_book_info = nil -- captured pre-patch; needed by list_strip
-- Tracks file_paths that came from our LibraryStore (= entries we
-- render in the Library widget). The BIM patch tags returned info
-- with _no_provider so ListMenuItem.update renders mandatory verbatim
-- (the format string), keeping right-side text right-aligned with
-- cloud rows that already use _no_provider.
local _library_local_paths = {}
-- Sentinel used by entry_from_row to flag cloud-only rows. Re-exported
-- here so the patches can read it without a circular libraryitem import.
M.CLOUD_ONLY_FLAG = "_readest_cloud_only"
M.LOCAL_ONLY_FLAG = "_readest_local_only"
function M.register_local_path(path)
_library_local_paths[path] = true
end
function M.orig_get_book_info()
return _orig_get_book_info
end
-- Patch BIM:getBookInfo with a router that dispatches on URI prefix.
-- Idempotent.
local function patch_bim(opts)
if _bim_patched then return end
local ok, BIM = pcall(require, "bookinfomanager")
if not ok or not BIM then
logger.warn("ReadestLibrary bim_patch: bookinfomanager not available")
return
end
_bim_patched = true
_orig_get_book_info = BIM.getBookInfo
local function build_cloud_info(filepath, do_cover_image)
local hash = cloud_covers.hash_from_uri(filepath)
local meta = cloud_covers.get_meta(hash)
local info = {
has_meta = true,
cover_fetched = true,
ignore_cover = false,
title = meta.title,
authors = meta.author,
has_cover = false,
-- Render mandatory verbatim (no "<filetype> size" prefix).
_no_provider = true,
}
if do_cover_image then
local bb = cloud_covers.load_cover_bb(hash)
if bb then
local w, h = bb:getWidth(), bb:getHeight()
info.cover_bb = bb
info.cover_w = w
info.cover_h = h
-- BookInfoManager.isCachedCoverInvalid (bookinfomanager.lua:1017)
-- crashes if cover_sizetag is nil. Format is "<w>x<h>".
info.cover_sizetag = w .. "x" .. h
info.has_cover = true
else
-- Lazy fetch: only currently-visible cells trigger.
cloud_covers.trigger_download(hash)
end
end
return info
end
local function build_group_info(filepath, do_cover_image)
local group_by, value, shape = group_covers.parse_uri(filepath)
local meta = cloud_covers.get_meta(filepath)
local info = {
has_meta = true,
cover_fetched = true,
ignore_cover = false,
title = meta.title,
authors = meta.author,
has_cover = false,
_no_provider = true,
}
if do_cover_image and group_by and value then
local LibraryWidget = package.loaded["library.librarywidget"]
local store = LibraryWidget and LibraryWidget._store
local settings = M._opts and M._opts.settings or {}
local bb = group_covers.serve_or_compose(
group_by, value, shape,
store, settings, _orig_get_book_info, BIM)
if bb then
local w, h = bb:getWidth(), bb:getHeight()
info.cover_bb = bb
info.cover_w = w
info.cover_h = h
info.cover_sizetag = w .. "x" .. h
info.has_cover = true
end
end
return info
end
function BIM:getBookInfo(filepath, do_cover_image)
if type(filepath) == "string" then
if filepath:sub(1, #cloud_covers.URI_PREFIX) == cloud_covers.URI_PREFIX then
return build_cloud_info(filepath, do_cover_image)
end
if filepath:sub(1, #group_covers.URI_PREFIX) == group_covers.URI_PREFIX then
return build_group_info(filepath, do_cover_image)
end
end
-- Real local file: forward to the original BIM, then add
-- _no_provider for paths that came from our LibraryStore so
-- the right-side text right-aligns with cloud rows. Shallow
-- copy first so we don't mutate BIM's cached entry.
local result = _orig_get_book_info(self, filepath, do_cover_image)
if result and type(filepath) == "string" and _library_local_paths[filepath] then
local copy = {}
for k, v in pairs(result) do copy[k] = v end
copy._no_provider = true
return copy
end
return result
end
end
-- Locate listmenu's local ListMenuItem class via its captured upvalue
-- on the exported _updateItemsBuildUI mixin. Cheapest path that
-- doesn't require modifying coverbrowser.koplugin or copy-pasting the
-- ~50-line build loop.
local function patch_list_menu_item()
if _list_item_patched then return end
local debug = require("debug")
local ok, ListMenu = pcall(require, "listmenu")
if not ok or type(ListMenu._updateItemsBuildUI) ~= "function" then return end
local ListMenuItem
for i = 1, 50 do
local name, val = debug.getupvalue(ListMenu._updateItemsBuildUI, i)
if not name then break end
if name == "ListMenuItem" and type(val) == "table" then
ListMenuItem = val
break
end
end
if not ListMenuItem or type(ListMenuItem.update) ~= "function" then
logger.warn("ReadestLibrary: couldn't locate ListMenuItem class for patching")
return
end
-- Custom group-row widget tree (wider cover strip).
local orig_update = ListMenuItem.update
function ListMenuItem:update()
if self.entry and self.entry._readest_group then
local LibraryWidget = package.loaded["library.librarywidget"]
return list_strip.build(self, {
store = LibraryWidget and LibraryWidget._store,
settings = M._opts and M._opts.settings,
orig_getBookInfo = _orig_get_book_info,
})
end
return orig_update(self)
end
-- Cloud icon overlay painted on top of the standard widget tree.
-- cloud-only (cloud_present=1, local_present=0) → download icon
-- local-only (cloud_present=0, local_present=1) → upload icon
local orig_paint = ListMenuItem.paintTo
function ListMenuItem:paintTo(bb, x, y)
orig_paint(self, bb, x, y)
if not self.entry then return end
if self.entry[M.CLOUD_ONLY_FLAG] and cloud_icons.has_icon("dl") then
cloud_icons.paint(self, bb, x, y, "dl")
elseif self.entry[M.LOCAL_ONLY_FLAG] and cloud_icons.has_icon("up") then
cloud_icons.paint(self, bb, x, y, "up")
end
end
_list_item_patched = true
logger.info("ReadestLibrary: patched ListMenuItem update + paintTo")
end
function M.install(opts)
M._opts = opts or {}
cloud_covers.set_opts(M._opts)
logger.info("ReadestLibrary bim_patch.install: opts="
.. (opts and "set" or "nil")
.. " sync_auth=" .. tostring(opts and opts.sync_auth ~= nil)
.. " bim_patched_before=" .. tostring(_bim_patched))
-- Both patches are idempotent so the order + repeated calls are
-- safe. ListMenuItem first since it doesn't need BIM.
patch_list_menu_item()
patch_bim()
end
return M