Commit f922edc4 authored by WorldTeacher's avatar WorldTeacher
Browse files

feat(shelf-sync): improved download dialog, handling

parent 0798caee
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -26,4 +26,11 @@ generate_version: ## Generate version file
	sh ./scripts/generate-version.sh

changelog-pregen: ## Pre-generate changelog (CHG_ARGS=--docs for docs changelog)
	@if echo "$(CHG_ARGS)" | grep -q -- '--docs'; then \
		current_branch=$$(git rev-parse --abbrev-ref HEAD 2>/dev/null); \
		if [ "$$current_branch" != "develop" ]; then \
			echo "ERROR: --docs can only be used on the 'develop' branch (current: $$current_branch)" >&2; \
			exit 1; \
		fi; \
	fi
	bash ./scripts/changelog-pregen.sh $(CHG_ARGS)
+168 −0
Original line number Diff line number Diff line
@@ -2114,6 +2114,174 @@ function APIClient:downloadBook(book_id, save_path, username, password)
    return false, err_msg
end

--[[--
Download a book file with progress reporting and cancellation support.

Uses a custom LuaSocket sink that counts bytes written per chunk and
optionally reports progress and checks for cancellation.

@param book_id   number  Book ID to download
@param save_path string  Destination file path
@param username  string  Booklore username (for Bearer auth)
@param password  string  Booklore password
@param opts      table|nil  Optional table:
  on_progress = function(bytes_written, total_bytes)  — called at most ~1/s
  is_cancelled = function() → bool                     — called per chunk
@return boolean success
@return string|nil error or nil on success
--]]
---APIClient:downloadBookWithProgress.
function APIClient:downloadBookWithProgress(book_id, save_path, username, password, opts)
    opts = opts or {}
    self:logInfo("BookloreSync API: downloadBookWithProgress - id:", book_id, "->", save_path)

    local token_ok, token = self:getOrRefreshBearerToken(username, password)
    if not token_ok then
        self:logErr("BookloreSync API: downloadBookWithProgress - auth failed:", token)
        return false, "Authentication failed"
    end

    local url = self.server_url .. "/api/v1/books/" .. book_id .. "/download"
    local http_client = url:match("^https://") and https or http

    -- Write to a temp file, then rename to avoid partial reads.
    local tmp_path = save_path .. ".tmp"
    local file, open_err = io.open(tmp_path, "wb")
    if not file then
        self:logErr("BookloreSync API: downloadBookWithProgress - cannot open temp file:", open_err)
        return false, "Cannot create file: " .. tostring(open_err)
    end

    local bytes_written = 0
    local on_progress  = opts.on_progress
    local is_cancelled = opts.is_cancelled
    local last_progress_time = 0
    local file_closed = false
    local function close_file()
        if not file_closed then
            file_closed = true
            file:close()
        end
    end

    -- Custom sink: write chunk, track bytes, report progress, check cancel.
    local function progress_sink(chunk, sink_err)
        if chunk == nil then
            close_file()
            return nil
        end
        if chunk == "" then
            return 1
        end
        if is_cancelled and is_cancelled() then
            close_file()
            os.remove(tmp_path)
            return nil, "cancelled"
        end
        local write_ok, write_err = file:write(chunk)
        if not write_ok then
            close_file()
            return nil, write_err
        end
        bytes_written = bytes_written + #chunk
        local now = os.time()
        if on_progress and now ~= last_progress_time then
            last_progress_time = now
            on_progress(bytes_written, nil)
        end
        return 1
    end

    local req_args = {
        url     = url,
        method  = "GET",
        headers = { ["Authorization"] = "Bearer " .. token },
        sink    = progress_sink,
    }
    http_client.TIMEOUT = self.timeout

    local res, code, _ = http_client.request(req_args)

    -- Retry on 401/403
    if type(code) == "number" and (code == 401 or code == 403) then
        self:logWarn("BookloreSync API: downloadBookWithProgress - token rejected, refreshing")
        if self.db then self.db:deleteBearerToken(username) end
        local ref_ok, new_token = self:getOrRefreshBearerToken(username, password, true)
        if ref_ok then
            close_file()
            bytes_written = 0
            file, open_err = io.open(tmp_path, "wb")
            if file then
                file_closed = false
                req_args.headers = { ["Authorization"] = "Bearer " .. new_token }
                -- Recreate sink for the fresh file
                local function retry_sink(chunk, sink_err)
                    if chunk == nil then
                        close_file()
                        return nil
                    end
                    if chunk == "" then
                        return 1
                    end
                    if is_cancelled and is_cancelled() then
                        close_file()
                        os.remove(tmp_path)
                        return nil, "cancelled"
                    end
                    local write_ok2, write_err2 = file:write(chunk)
                    if not write_ok2 then
                        close_file()
                        return nil, write_err2
                    end
                    bytes_written = bytes_written + #chunk
                    local now2 = os.time()
                    if on_progress and now2 ~= last_progress_time then
                        last_progress_time = now2
                        on_progress(bytes_written, nil)
                    end
                    return 1
                end
                req_args.sink = retry_sink
                res, code, _ = http_client.request(req_args)
            end
        end
    end

    close_file()

    -- Check cancellation first
    if is_cancelled and is_cancelled() then
        os.remove(tmp_path)
        return false, "Download cancelled"
    end

    if not code then
        os.remove(tmp_path)
        local err_msg = "Network error: " .. tostring(res)
        self:logErr("BookloreSync API: downloadBookWithProgress failed:", err_msg)
        return false, err_msg
    end

    if type(code) == "number" and code >= 200 and code < 300 then
        -- Final progress report at 100%
        if on_progress and bytes_written > 0 then
            on_progress(bytes_written, bytes_written)
        end
        local rename_ok, rename_err = os.rename(tmp_path, save_path)
        if not rename_ok then
            os.remove(tmp_path)
            return false, "Failed to save file: " .. tostring(rename_err)
        end
        self:logInfo("BookloreSync API: downloadBookWithProgress - success,", bytes_written, "bytes")
        return true, nil
    end

    os.remove(tmp_path)
    local err_msg = "HTTP " .. tostring(code) .. ": download failed"
    self:logErr("BookloreSync API: downloadBookWithProgress failed:", err_msg)
    return false, err_msg
end

--[[--
Get the file size of a book by making an HTTP HEAD request.

+763 −720

File changed.

Preview size limit exceeded, changes collapsed.

+715 −630

File changed.

Preview size limit exceeded, changes collapsed.

+794 −670

File changed.

Preview size limit exceeded, changes collapsed.

Loading