Ctrl K
ComunidadGuías…

koreader-plugin/bookorbit.koplugin/bookorbit_catalog_thumbnails.lua

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
--[[--
Thumbnail cache mixin for the BookOrbit catalog browser.

Owns the on-disk cover cache (cache/bookorbit): versioned paths, one bounded
background worker, per-page path memoization and write-triggered pruning.
Installed onto the catalog controller as regular methods.

Covers download inside an API subprocess, a batch at a time, and publish with
an atomic rename out of a generation-scoped temporary directory, so the UI
never blocks on a cover and a page the user already left cannot repaint or
leave a partial file behind.
]]

local DataStorage = require("datastorage")
local NetworkMgr = require("ui/network/manager")
local Trapper = require("ui/trapper")
local UIManager = require("ui/uimanager")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local util = require("util")

local CatalogUtil = require("bookorbit_catalog_util")

local cloneParams = CatalogUtil.cloneParams
local THUMBNAIL_BATCH_SIZE = CatalogUtil.THUMBNAIL_BATCH_SIZE

-- Soft cap on the catalog thumbnail cache (cache/bookorbit). Oldest covers are
-- evicted past this many files so the cache cannot grow without bound.
local THUMBNAIL_CACHE_MAX_FILES = 600
-- Pruning normally runs once per catalog session. Past this many files the
-- eviction cannot wait for the next session.
local THUMBNAIL_CACHE_HARD_FILES = 900
local THUMBNAIL_MAX_BYTES = 4 * 1024 * 1024

local TEMP_DIRNAME = "tmp"
local TEMP_STALE_SECONDS = 600
local TEMP_SCAN_LIMIT = 200
local MAINTENANCE_ENTRY_BUDGET = 100
local MAINTENANCE_TIME_BUDGET = 0.02
local MAINTENANCE_STEP_DELAY = 0.05

local WORKER_START_DELAY = 0.15
local WORKER_STEP_DELAY = 0.05
local PREFETCH_START_DELAY = 0.25
local PREFETCH_PAGE_DELAY = 1.0

-- Bumped when a cache layout change needs a one-off cleanup; each version runs
-- once and is then recorded in settings.
local CACHE_CLEANUP_VERSION = 1

local CatalogThumbnails = {}
local next_cache_session_id = 0

local function now()
    return UIManager:getElapsedTimeSinceBoot()
end

-- Cover cache files are versioned by the book's updatedAt so a server-side
-- cover change or a library rescan (which reassigns book ids) invalidates the
-- cached image instead of showing the previous book's cover.
local function coverToken(book)
    local value = book and book.updatedAt
    if not value then return "0" end
    local token = tostring(value):gsub("%D", "")
    if #token > 32 then
        token = token:sub(1, 16) .. token:sub(-16)
    end
    return token ~= "" and token or "0"
end

local function normalizedBookId(book)
    local id = book and book.id
    if type(id) ~= "number" or id ~= math.floor(id) or id < 1 or id > 2147483647 then
        return nil
    end
    return string.format("%.0f", id)
end

-- Author portraits share the cache under their own prefix: an author id and
-- a book id may be the same number.
local function memoKey(book)
    local id = normalizedBookId(book)
    if not id then return nil end
    return (book.author_photo and "a" or "") .. id .. "_" .. coverToken(book)
end

local function newQueue()
    return { head = 1, tail = 0, seen = {} }
end

local function takeBatch(queue, limit)
    local batch = {}
    while #batch < limit and queue.head <= queue.tail do
        batch[#batch + 1] = queue[queue.head]
        queue[queue.head] = nil
        queue.head = queue.head + 1
    end
    if queue.head > queue.tail then
        queue.head, queue.tail, queue.seen = 1, 0, {}
    end
    return batch
end

function CatalogThumbnails:initThumbnailCache()
    next_cache_session_id = next_cache_session_id + 1
    self.thumbnail_cache_dir = DataStorage:getDataDir() .. "/cache/bookorbit"
    self.thumbnail_temp_dir = self.thumbnail_cache_dir .. "/" .. TEMP_DIRNAME
    self.thumbnail_cache_session_id = next_cache_session_id
    self.thumbnail_cache_ready = util.makePath(self.thumbnail_cache_dir) and true or false
    self.thumbnail_generation = 0
    self.thumbnail_failures = {}
    self.thumbnail_memo = {}
    self.thumbnail_queue = newQueue()
    self.thumbnail_prefetch_queue = newQueue()
    self.thumbnail_worker_running = false
    self.thumbnail_worker_generation = nil
    self.thumbnail_cache_written = false
    self.thumbnail_cache_pruned = false
    self.thumbnail_temp_swept = false
    self.thumbnail_cleanup_state = nil
    self.thumbnail_prune_state = nil
end

-- The cache directory is created once at catalog init; this only retries when
-- that failed, and never runs on a render path.
function CatalogThumbnails:ensureThumbnailCacheDir()
    if self.thumbnail_cache_ready then return true end
    self.thumbnail_cache_ready = util.makePath(self.thumbnail_cache_dir) and true or false
    return self.thumbnail_cache_ready
end

-- Removes pre-versioning cache files ("<id>.jpg"). They were keyed by book id
-- only, so after a library rescan reassigned ids they showed the wrong cover.
-- Recorded in settings so the same obsolete walk is not repeated forever.
function CatalogThumbnails:cleanLegacyThumbnails()
    if self.catalog_closed then
        local state = self.thumbnail_cleanup_state
        if state and state.iterator_state and state.iterator_state.close then
            pcall(state.iterator_state.close, state.iterator_state)
        end
        self.thumbnail_cleanup_state = nil
        return
    end
    local done = tonumber(self.settings and self.settings.catalog_thumbnail_cleanup_version) or 0
    if done >= CACHE_CLEANUP_VERSION then return end

    local dir = self.thumbnail_cache_dir
    if not dir or lfs.attributes(dir, "mode") ~= "directory" then
        self:persistSetting("catalog_thumbnail_cleanup_version", CACHE_CLEANUP_VERSION)
        return
    end

    if not self.thumbnail_cleanup_state then
        local iterator, iterator_state = lfs.dir(dir)
        self.thumbnail_cleanup_state = {
            iterator = iterator,
            iterator_state = iterator_state,
        }
    end

    local state = self.thumbnail_cleanup_state
    local deadline = now() + MAINTENANCE_TIME_BUDGET
    local processed = 0
    while processed < MAINTENANCE_ENTRY_BUDGET and now() <= deadline do
        local name = state.iterator(state.iterator_state)
        if not name then
            if state.iterator_state and state.iterator_state.close then
                pcall(state.iterator_state.close, state.iterator_state)
            end
            self.thumbnail_cleanup_state = nil
            self:persistSetting("catalog_thumbnail_cleanup_version", CACHE_CLEANUP_VERSION)
            return
        end
        processed = processed + 1
        if name:match("^%d+%.jpg$") then
            os.remove(dir .. "/" .. name)
        end
    end

    UIManager:scheduleIn(MAINTENANCE_STEP_DELAY, function()
        self:cleanLegacyThumbnails()
    end)
end

-- Path, existence and display state for one cover, memoized per book id and
-- cover token so a repaint costs a table lookup instead of a filesystem probe.
function CatalogThumbnails:thumbnailEntry(book)
    if not book or book.id == nil or not book.hasCover then return nil end
    local key = memoKey(book)
    if not key then return nil end
    local entry = self.thumbnail_memo[key]
    if entry then return entry end
    entry = { path = self.thumbnail_cache_dir .. "/" .. key .. ".jpg" }
    entry.exists = lfs.attributes(entry.path, "mode") == "file"
    self.thumbnail_memo[key] = entry
    return entry
end

function CatalogThumbnails:thumbnailPath(book)
    local entry = self:thumbnailEntry(book)
    return entry and entry.path or nil
end

function CatalogThumbnails:cachedThumbnailPath(book)
    local entry = self:thumbnailEntry(book)
    if entry and entry.exists then return entry.path end
    return nil
end

-- Combined lookup for item builders: one memo hit per rebuilt widget instead
-- of a separate path probe and state probe.
function CatalogThumbnails:thumbnailDisplay(book)
    local entry = self:thumbnailEntry(book)
    if not entry then return nil, "missing" end
    if entry.exists then return entry.path, "ready" end
    if self.thumbnail_failures[memoKey(book)] then return nil, "failed" end
    return nil, "loading"
end

function CatalogThumbnails:thumbnailState(book)
    local _, state = self:thumbnailDisplay(book)
    return state
end

function CatalogThumbnails:invalidateThumbnail(book, exists)
    if not book or book.id == nil then return end
    local entry = self.thumbnail_memo[memoKey(book)]
    if entry then entry.exists = exists == true end
end

function CatalogThumbnails:resetThumbnailMemo()
    self.thumbnail_memo = {}
end

-- Deletes cached covers for the given books so a refresh re-downloads them,
-- guaranteeing fresh covers even if the cover changed without a new version.
function CatalogThumbnails:evictCachedCovers(books)
    for _, book in ipairs(books or {}) do
        local path = self:thumbnailPath(book)
        if path then
            os.remove(path)
            self:invalidateThumbnail(book, false)
        end
        local key = memoKey(book)
        if key then self.thumbnail_failures[key] = nil end
    end
end

function CatalogThumbnails:cancelThumbnailJobs()
    self.thumbnail_generation = self.thumbnail_generation + 1
    self.thumbnail_queue = newQueue()
    self.thumbnail_prefetch_queue = newQueue()
    self.thumbnail_worker_running = false
    self.thumbnail_worker_generation = nil
    self:resetThumbnailMemo()
end

function CatalogThumbnails:queueThumbnails(queue, items)
    local added = 0
    for _, book in ipairs(items or {}) do
        local key = memoKey(book)
        if key and book.hasCover and not queue.seen[key]
                and not self:cachedThumbnailPath(book)
                and not self.thumbnail_failures[key] then
            queue.seen[key] = true
            queue.tail = queue.tail + 1
            queue[queue.tail] = book
            added = added + 1
        end
    end
    return added
end

function CatalogThumbnails:scheduleThumbnailDownloads(items)
    if self:queueThumbnails(self.thumbnail_queue, items) == 0 then return end
    self:startThumbnailWorker(WORKER_START_DELAY)
end

-- Queues covers for a future page. They are drained only once the visible
-- queue is empty and never trigger a repaint.
function CatalogThumbnails:prefetchThumbnails(items, generation)
    if generation ~= self.thumbnail_generation then return end
    if self:queueThumbnails(self.thumbnail_prefetch_queue, items) == 0 then return end
    self:startThumbnailWorker(PREFETCH_START_DELAY)
end

function CatalogThumbnails:startThumbnailWorker(delay)
    if self.thumbnail_worker_running then return end
    local generation = self.thumbnail_generation
    self.thumbnail_worker_running = true
    self.thumbnail_worker_generation = generation
    UIManager:scheduleIn(delay or WORKER_START_DELAY, function()
        self:runThumbnailWorker(generation)
    end)
end

function CatalogThumbnails:stopThumbnailWorker(generation)
    if self.thumbnail_worker_generation ~= generation then return end
    self.thumbnail_worker_running = false
    self.thumbnail_worker_generation = nil
end

function CatalogThumbnails:runThumbnailWorker(generation)
    if generation ~= self.thumbnail_generation then
        self:stopThumbnailWorker(generation)
        return
    end

    local visible = true
    local batch = takeBatch(self.thumbnail_queue, THUMBNAIL_BATCH_SIZE)
    if #batch == 0 then
        visible = false
        batch = takeBatch(self.thumbnail_prefetch_queue, THUMBNAIL_BATCH_SIZE)
    end
    if #batch == 0 then
        self:stopThumbnailWorker(generation)
        self:maybePruneThumbnailCache()
        return
    end

    Trapper:wrap(function()
        local ok, results = pcall(self.downloadThumbnailBatch, self, batch, generation)
        if generation ~= self.thumbnail_generation then
            self:stopThumbnailWorker(generation)
            return
        end
        if not ok or not results then
            -- A failed or interrupted worker says nothing about these covers;
            -- leave them unmarked so a later visit retries them, and release
            -- the worker so the next page is not stuck behind it.
            if not ok then logger.dbg("BookOrbit: thumbnail batch failed", results) end
            self:stopThumbnailWorker(generation)
            return
        end

        local written = false
        for _, book in ipairs(batch) do
            local key = memoKey(book)
            if key and results[key] then
                self.thumbnail_failures[key] = nil
                self:invalidateThumbnail(book, true)
                self.thumbnail_cache_files = (self.thumbnail_cache_files or 0) + 1
                written = true
            elseif key then
                self.thumbnail_failures[key] = true
                self:invalidateThumbnail(book, false)
            end
        end
        if written then self.thumbnail_cache_written = true end

        -- One coalesced rebuild per completed visible batch. Prefetch never
        -- repaints, so warming the next page costs no e-ink refresh.
        if visible and written then
            self:updateItems(nil, true)
        end

        UIManager:scheduleIn(WORKER_STEP_DELAY, function()
            self:runThumbnailWorker(generation)
        end)
    end)
end

-- Runs a whole batch inside one API subprocess. The child writes each cover to
-- a generation-scoped temporary file, publishes it with an atomic rename and
-- returns only a bounded book id -> ok table; it touches no widget or setting.
-- Returns nil when the worker itself was interrupted, which is not a verdict
-- on the covers.
function CatalogThumbnails:downloadThumbnailBatch(batch, generation)
    if not self:ensureThumbnailCacheDir() then return {} end
    self:sweepThumbnailTemp()

    local jobs = {}
    for _, book in ipairs(batch) do
        local path = self:thumbnailPath(book)
        local key = memoKey(book)
        if path and key then
            local temp = path .. ".part"
            if self.thumbnail_temp_dir then
                temp = self.thumbnail_temp_dir .. "/"
                    .. self.thumbnail_cache_session_id .. "_" .. generation .. "_" .. key .. ".part"
            end
            jobs[#jobs + 1] = { id = book.id, key = key, path = path, temp = temp, author_photo = book.author_photo == true }
        end
    end
    if #jobs == 0 then return {} end

    local client = self.client
    local completed, result = client:runInSubprocess(function()
        local outcome = {}
        for _, job in ipairs(jobs) do
            local download = job.author_photo and client.downloadCatalogAuthorPhoto or client.downloadCatalogThumbnail
            local ok, err = download(client, job.id, job.path, {
                temp_path = job.temp,
                max_bytes = THUMBNAIL_MAX_BYTES,
                background = false,
            })
            if not ok then
                logger.dbg("BookOrbit: thumbnail download failed", job.id, err)
            end
            outcome[job.key] = ok == true
        end
        return outcome
    end)
    if not completed then return nil end
    return (result and result.body) or {}
end

-- Removes temporary files left behind by a killed or cancelled worker. Runs
-- once per catalog session and is bounded, so it cannot delay the first cover.
function CatalogThumbnails:sweepThumbnailTemp()
    if self.thumbnail_temp_swept then return end
    self.thumbnail_temp_swept = true
    if not util.makePath(self.thumbnail_temp_dir) then
        self.thumbnail_temp_dir = nil
        return
    end

    local now = os.time()
    local scanned = 0
    for name in lfs.dir(self.thumbnail_temp_dir) do
        if name:match("%.part$") then
            scanned = scanned + 1
            if scanned > TEMP_SCAN_LIMIT then break end
            local path = self.thumbnail_temp_dir .. "/" .. name
            local mtime = lfs.attributes(path, "modification")
            if not mtime or now - mtime > TEMP_STALE_SECONDS then
                os.remove(path)
            end
        end
    end
end

-- Maintenance is write-triggered: a session that only reads cached covers
-- never walks the cache directory.
function CatalogThumbnails:maybePruneThumbnailCache()
    if not self.thumbnail_cache_written then return end
    if self.thumbnail_prune_state then return end
    self.thumbnail_cache_written = false
    if self.thumbnail_cache_pruned
            and (self.thumbnail_cache_files or 0) < THUMBNAIL_CACHE_HARD_FILES then
        return
    end
    self.thumbnail_cache_pruned = false
    self:pruneThumbnailCache()
end

local function heapSwap(heap, a, b)
    heap[a], heap[b] = heap[b], heap[a]
end

local function heapPush(heap, value)
    heap[#heap + 1] = value
    local index = #heap
    while index > 1 do
        local parent = math.floor(index / 2)
        if heap[parent].mtime <= value.mtime then break end
        heap[index] = heap[parent]
        index = parent
    end
    heap[index] = value
end

local function heapReplaceOldest(heap, value)
    local removed = heap[1]
    heap[1] = value
    local index = 1
    while true do
        local left = index * 2
        if left > #heap then break end
        local right = left + 1
        local child = right <= #heap and heap[right].mtime < heap[left].mtime and right or left
        if heap[index].mtime <= heap[child].mtime then break end
        heapSwap(heap, index, child)
        index = child
    end
    return removed
end

-- Keeps the newest soft-cap covers with bounded wall-clock work per callback.
function CatalogThumbnails:pruneThumbnailCache()
    if self.catalog_closed then
        local state = self.thumbnail_prune_state
        if state and state.iterator_state and state.iterator_state.close then
            pcall(state.iterator_state.close, state.iterator_state)
        end
        self.thumbnail_prune_state = nil
        return
    end
    local dir = self.thumbnail_cache_dir
    if not dir or lfs.attributes(dir, "mode") ~= "directory" then return end

    if not self.thumbnail_prune_state then
        local iterator, iterator_state = lfs.dir(dir)
        self.thumbnail_prune_state = {
            iterator = iterator,
            iterator_state = iterator_state,
            newest = {},
            removed = 0,
            removal_failures = 0,
        }
    end

    local state = self.thumbnail_prune_state
    local deadline = now() + MAINTENANCE_TIME_BUDGET
    local processed = 0
    while processed < MAINTENANCE_ENTRY_BUDGET and now() <= deadline do
        local name = state.iterator(state.iterator_state)
        if not name then
            if state.iterator_state and state.iterator_state.close then
                pcall(state.iterator_state.close, state.iterator_state)
            end
            self.thumbnail_cache_files = #state.newest
                + state.removal_failures
            self.thumbnail_cache_pruned = state.removal_failures == 0
            self.thumbnail_prune_state = nil
            if state.removed > 0 then self:resetThumbnailMemo() end
            if self.thumbnail_cache_written then
                UIManager:scheduleIn(MAINTENANCE_STEP_DELAY, function()
                    self:maybePruneThumbnailCache()
                end)
            end
            return
        end

        processed = processed + 1
        if name:match("%.jpg$") then
            local item = {
                path = dir .. "/" .. name,
                mtime = lfs.attributes(dir .. "/" .. name, "modification"),
            }
            if item.mtime then
                if #state.newest < THUMBNAIL_CACHE_MAX_FILES then
                    heapPush(state.newest, item)
                elseif item.mtime > state.newest[1].mtime then
                    local removed = heapReplaceOldest(state.newest, item)
                    if os.remove(removed.path) then
                        state.removed = state.removed + 1
                    else
                        state.removal_failures = state.removal_failures + 1
                    end
                elseif os.remove(item.path) then
                    state.removed = state.removed + 1
                else
                    state.removal_failures = state.removal_failures + 1
                end
            end
        end
    end

    UIManager:scheduleIn(MAINTENANCE_STEP_DELAY, function()
        self:pruneThumbnailCache()
    end)
end

-- Fetches the next page's book list off the UI thread and warms its covers.
-- Only local link state gates this: a background prefetch must never prompt
-- the user to connect.
-- next_offset, when the list pages by offset, names the next page instead.
function CatalogThumbnails:prefetchNextPage(query, next_page, next_offset)
    if not next_page and not next_offset then return end
    local generation = self.thumbnail_generation
    UIManager:scheduleIn(PREFETCH_PAGE_DELAY, function()
        if generation ~= self.thumbnail_generation then return end
        if not NetworkMgr:isConnected() then return end
        Trapper:wrap(function()
            local q = cloneParams(query)
            q.page = next_page or q.page
            q.offset = next_offset
            -- The same page number paging will send, so the saved copy matches.
            if next_offset and tonumber(q.size) then q.page = math.floor(next_offset / tonumber(q.size)) + 1 end
            local completed, result = self.client:runInSubprocess(function()
                return self.client:catalogBooks(q)
            end)
            if not completed or generation ~= self.thumbnail_generation then return end
            local body = result and result.body
            if body and body.items then
                if self.listSavePage then self:listSavePage(q, body) end
                self:prefetchThumbnails(body.items, generation)
            end
        end)
    end)
end

function CatalogThumbnails.install(Catalog)
    for name, fn in pairs(CatalogThumbnails) do
        if name ~= "install" then
            Catalog[name] = fn
        end
    end
end

return CatalogThumbnails