Ctrl K
ComunidadGuías…

koreader-plugin/bookorbit.koplugin/bookorbit_catalog_download.lua

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

Archivo original ↗Preguntar a KiBot
--[[--
Download manager mixin for the BookOrbit catalog browser.

Installs the file-choice, download, link-to-sync and open-file methods onto the
catalog controller. Downloads stay foreground with live byte/percent progress
(a deliberate user action), unlike navigation which runs off the UI thread.
]]

local UIManager = require("ui/uimanager")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local util = require("util")
local T = require("ffi/util").template
local _ = require("gettext")

local BookOrbitStateManager = require("bookorbit_state_manager")
local CatalogUtil = require("bookorbit_catalog_util")
local DownloadRoot = require("bookorbit_download_root")
local Transfer = require("bookorbit_download_transfer")
local Delivery = require("bookorbit_delivery")
local DownloadSheet = setmetatable({}, { __index = function(_t, key) return require("bookorbit_download_sheet")[key] end })
-- Loaded on first use: specs load this module without KOReader's widgets.
local Toast = setmetatable({}, { __index = function(_t, key) return require("bookorbit_toast")[key] end })

local formatBytes = CatalogUtil.formatBytes
local safeFilenameBase = CatalogUtil.safeFilenameBase

local CatalogDownload = {}

local function sanitizeDevicePath(device_path, download_dir)
    local normalized = tostring(device_path or ""):gsub("\\", "/"):gsub("^/+", "")
    local segments = {}
    for segment in normalized:gmatch("[^/]+") do
        if segment == ".." then return nil end
        if segment ~= "." then
            local safe_segment = util.getSafeFilename(segment, download_dir)
            if not safe_segment or safe_segment == "" or safe_segment == "." or safe_segment == ".." then return nil end
            table.insert(segments, safe_segment)
        end
    end
    if #segments == 0 then return nil end
    return table.concat(segments, "/")
end

local function joinDownloadPath(download_dir, relative_path)
    return (download_dir ~= "/" and download_dir or "") .. "/" .. relative_path
end

local function resolveRelativeDownloadPath(download_dir, filename, filetype, device_path, filename_override)
    local relative = sanitizeDevicePath(device_path, download_dir)
    if relative and not filename_override then return relative end

    local parent = relative and relative:match("^(.*)/[^/]+$")
    local safe_filename = util.getSafeFilename(filename .. "." .. string.lower(filetype or "bin"), download_dir)
    return parent and parent ~= "" and parent .. "/" .. safe_filename or safe_filename
end

local function splitDownloadPreview(download_dir, relative_path, filetype)
    local parent, filename = relative_path:match("^(.*)/([^/]+)$")
    local folder = parent and parent ~= "" and joinDownloadPath(download_dir, parent) or download_dir
    filename = filename or relative_path
    local extension = "." .. string.lower(filetype or "bin")
    if filename:sub(-#extension):lower() == extension then
        filename = filename:sub(1, #filename - #extension)
    end
    return folder, filename
end

function CatalogDownload.install(Catalog)
    function Catalog:fetchDeliveryPreference(variant, update)
        local message = update and _("Saving EPUB delivery preference...") or _("Loading EPUB delivery preference...")
        local body, err = self:fetch(message, function()
            local resolved, resolve_err
            if update then resolved, resolve_err = Delivery.update(self.settings, self.client, variant)
            else resolved, resolve_err = Delivery.resolve(self.settings, self.client, variant) end
            if resolve_err then return nil, resolve_err end
            -- Only returned values cross the fork; apply settings and caches in the parent.
            return {
                variant = resolved,
                delivery_settings = self.client.delivery_settings,
                profiles = self.settings.catalog_delivery_profiles,
                server_profiles = self.settings.catalog_delivery_server_profiles,
            }
        end)
        if not body then return nil, err end
        self.client.delivery_settings = body.delivery_settings
        if body.profiles then self:persistSetting("catalog_delivery_profiles", body.profiles) end
        if body.server_profiles then self:persistSetting("catalog_delivery_server_profiles", body.server_profiles) end
        return body.variant
    end

    function Catalog:fetchDeliveryBook(book_id, override, message)
        local variant, err = self:fetchDeliveryPreference(override)
        if err then return nil, err end
        return self:fetch(message or _("Loading book..."), function()
            return self.client:catalogBook(book_id, variant)
        end)
    end

    function Catalog:changeDownloadVariant(detail, file, use_default)
        local variant = file.downloadVariant == "original" and "audioless_epub" or "original"
        if use_default then variant = nil end
        DownloadSheet.open(self, detail, { file = file, variant = variant })
    end

    -- A book with several files: the download sheet lists them. One file
    -- opens or downloads straight away.
    function Catalog:showFileChoices(detail)
        local files = self:supportedFiles(detail)
        if #files == 0 then
            Toast.error(_("No file this reader can open"))
            return
        end
        if #files == 1 then
            local path = self:onDeviceFilePath(files[1])
            if path then
                self:openDownloadedFile(path)
            else
                self:downloadDefaultFile(detail, files[1])
            end
            return
        end
        DownloadSheet.open(self, detail)
    end

    function Catalog:getCurrentDownloadDir()
        return DownloadRoot.get()
    end

    -- Single and bulk downloads share one destination, KOReader's own
    -- download_dir, so the chooser is shared too rather than each dialog
    -- keeping its own idea of where files land.
    function Catalog:chooseDownloadDir(on_chosen)
        require("ui/downloadmgr"):new{
            onConfirm = function(path)
                logger.dbg("BookOrbit: download folder set to", path)
                if self._manager and self._manager.ui and self._manager.ui.folder_shortcuts
                    and self._manager.ui.folder_shortcuts.updateShortcut then
                    self._manager.ui.folder_shortcuts:updateShortcut("download_dir", path)
                end
                G_reader_settings:saveSetting("download_dir", path)
                G_reader_settings:flush()
                if on_chosen then
                    UIManager:nextTick(function()
                        on_chosen(path)
                    end)
                end
            end,
        }:chooseDir(self:getCurrentDownloadDir())
    end

    function Catalog:getLocalDownloadPath(filename, filetype, device_path, filename_override)
        local download_dir = self:getCurrentDownloadDir()
        local relative = resolveRelativeDownloadPath(download_dir, filename, filetype, device_path, filename_override)
        local parent = relative:match("^(.*)/[^/]+$")
        if parent and parent ~= "" then util.makePath(joinDownloadPath(download_dir, parent)) end
        return joinDownloadPath(download_dir, relative)
    end

    function Catalog:getLocalDownloadPreview(filename, filetype, device_path, filename_override)
        local download_dir = self:getCurrentDownloadDir()
        local relative = resolveRelativeDownloadPath(download_dir, filename, filetype, device_path, filename_override)
        return splitDownloadPreview(download_dir, relative, filetype)
    end

    function Catalog:downloadDefaultFile(detail, file)
        local filename = safeFilenameBase(detail)
        local filetype = string.lower(file.format or "bin")
        local local_path = self:getLocalDownloadPath(filename, filetype, file.devicePath)
        self:checkDownloadFile(local_path, detail, file)
    end

    function Catalog:showDownloadOptions(detail)
        DownloadSheet.open(self, detail)
    end

    function Catalog:showDownloadDialog(detail, file, initial_filename_override)
        DownloadSheet.open(self, detail, { file = file, filename = initial_filename_override })
    end

    -- A file already at the path opens the download sheet, which says so and
    -- asks before replacing it.
    function Catalog:checkDownloadFile(local_path, detail, file)
        if lfs.attributes(local_path) then
            DownloadSheet.open(self, detail, { file = file })
            return
        end
        UIManager:nextTick(function()
            self:downloadFile(local_path, detail, file)
        end)
    end

    -- Cancelling bumps the generation. The child keeps running until its socket
    -- work ends, but its result is discarded and its temporary file removed, so
    -- a late completion can never publish over a cancelled download.
    function Catalog:nextDownloadGeneration()
        self.download_generation = (self.download_generation or 0) + 1
        return self.download_generation
    end

    function Catalog:downloadProgressText(filename, received, total)
        if total and total > 0 then
            local pct = math.min(100, math.floor(received / total * 100))
            return T(_("Downloading:\n%1\n\n%2"), filename, pct .. "%"), math.floor(pct / 5)
        end
        return T(_("Downloading:\n%1\n\n%2"), filename, formatBytes(received)),
            math.floor(received / (256 * 1024))
    end

    -- ui: the download sheet's reporter. Without one, a download started
    -- from the book's own page fills that page's button; anywhere else the
    -- download sheet opens to show it.
    function Catalog:downloadFile(local_path, detail, file, ui)
        local total = file.sizeBytes
        local root = self:getCurrentDownloadDir()
        local generation = self:nextDownloadGeneration()
        Transfer.sweepStale(root)

        local in_button = not ui and self.detailShowDownloadProgress ~= nil and self:detailShowDownloadProgress(detail, 0, total) == true
        if not ui and not in_button then
            local sheet = DownloadSheet.open(self, detail, { file = file, progress = true })
            ui = sheet and DownloadSheet.reporter(sheet) or nil
        end

        local last_bucket = -1
        local function onProgress(received)
            -- A poll that lands after cancellation must not resurrect the
            -- progress the user just dismissed.
            if self.download_generation ~= generation then return end
            local bucket = select(2, self:downloadProgressText("", received or 0, total))
            if bucket == last_bucket then return end
            last_bucket = bucket
            if in_button then
                -- The page moved on: the download goes on, and says when it is done.
                if not self:detailShowDownloadProgress(detail, received or 0, total) then in_button = false end
                return
            end
            if ui then ui.progress(received or 0, total) end
        end

        self:runOffThread(function()
            local ok, err = Transfer.run{
                root = root,
                destination = local_path,
                generation = generation,
                expected_bytes = total,
                expected_hash = file.fileHash,
                hash = "partial_md5",
                on_progress = onProgress,
                is_current = function()
                    return self.download_generation == generation
                end,
                perform = function(download_opts)
                    download_opts.variant = file.selection and (file.requestedVariant or file.downloadVariant) or nil
                    download_opts.selection = file.selection
                    return self.client:downloadCatalogFile(file.id, local_path, download_opts)
                end,
            }
            if in_button then self:detailShowDownloadProgress(detail, nil, nil, true) end
            if not ok then
                if ui then return ui.finished(false, err) end
                if err == "cancelled" then return end
                self:showRetry(err, function()
                    if err == 409 and file.selection then
                        return self:runConnected(function()
                            local selected, refresh_err = self:fetchDeliveryBook(detail.id, file.requestedVariant or file.downloadVariant)
                            if not selected then
                                if refresh_err ~= "cancelled" then self:showWriteError(refresh_err) end
                                return
                            end
                            for _, candidate in ipairs(selected.files or {}) do
                                if candidate.id == file.id then
                                    if self.current_context and self.current_context.detail
                                            and self.current_context.detail.id == detail.id then
                                        self.current_context.detail = selected
                                        self:refreshDetailView()
                                    end
                                    return self:checkDownloadFile(local_path, selected, candidate)
                                end
                            end
                            self:showWriteError(404)
                        end)
                    end
                    self:downloadFile(local_path, detail, file)
                end)
                return
            end

            if file.selection then
                local variants = self.settings.catalog_downloaded_variants or {}
                variants[Delivery.key(self.client) .. "|" .. tostring(file.id) .. "|" .. file.downloadVariant] = { path = local_path, selection = file.selection }
                self:persistSetting("catalog_downloaded_variants", variants)
            end
            local linked = self:linkDownloadedFile(local_path, file)
            if linked then
                self:refreshOnDevice()
                if self.markStackDirty then self:markStackDirty() end
                local on_catalog_page = (self.bookMode and self:bookMode())
                    or (self.dashboardMode and self:dashboardMode())
                if self.detailMode and self:detailMode() then
                    self:refreshDetailView()
                elseif self.updateItems and on_catalog_page then
                    self:updateItems()
                end
            end
            if ui then return ui.finished(true, nil, local_path, linked) end
            -- The page itself turns to Start reading; a short note is enough.
            if self.detailMode and self:detailMode() and not linked then
                self:refreshOnDevice()
                self:refreshDetailView()
            end
            Toast.show(_("Downloaded"))
        end)
    end

    function Catalog:linkDownloadedFile(local_path, file)
        local ok, digest = pcall(util.partialMD5, local_path)
        if not ok or not digest then
            logger.warn("BookOrbit: downloaded file partial MD5 failed", local_path)
            return false
        end

        local candidate = { source = "file" }
        if file and file.id then
            candidate.book_file_id = file.id
        end
        local body, err = self.client:matchCheck({ digest }, { [digest] = candidate })
        if not body then
            logger.warn("BookOrbit: downloaded file match-check failed", err)
            return false
        end
        BookOrbitStateManager.applyLibraryVersion(body.libraryVersion)

        for _, match in ipairs(body.matches or {}) do
            if match.hash == digest then
                -- Patches the cached on-device maps forward instead of forcing
                -- a full matched-library rescan per downloaded file.
                BookOrbitStateManager.linkFile(digest, match.bookFileId, match.bookId, local_path)
                return true
            end
        end
        BookOrbitStateManager.mutateScoped({
            digests = { digest },
            files = { local_path },
            global = false,
        }, function(state)
            state:rememberFile(local_path, digest)
            state:setUnmatched(digest)
        end)
        return false
    end

    function Catalog:openDownloadedFile(local_path)
        if type(local_path) ~= "string" or lfs.attributes(local_path, "mode") ~= "file" then
            self:updateItems(nil, true)
            Toast.error(_("This book is not on this device. Connect to BookOrbit to download it."))
            return
        end
        if self.close_callback then
            self.close_callback()
        else
            UIManager:close(self)
        end
        if self._manager and self._manager.ui then
            if self._manager.ui.document then
                self._manager.ui:switchDocument(local_path)
            else
                self._manager.ui:openFile(local_path)
            end
        else
            Toast.show(_("Downloaded"))
        end
    end
end

return CatalogDownload