如何使用子进程下载文件并更新PyQt6中的QProgressBar

问题描述 投票:0回答:1

我正在写一个程序来下载一堆文件,程序很长,我通过子进程调用aria2c.exe来下载文件,我遇到了问题。

具体来说,当使用 aria2c + subprocess + QThread 在后台下载文件时,GUI 挂起,并且下载运行时进度条和相关标签不会更新,GUI 保持无响应,直到下载完成。

我使用相同的方法在没有 GUI 的情况下使用 aria2c + subprocess + threading.Thread 在控制台中下载文件。下载成功完成,所有统计信息都正确更新,并且线程完成且没有错误。

这是重现该问题所需的最少代码,尽管它相当长:

import re
import requests
import subprocess
import sys
import time
from PyQt6.QtCore import Qt, QThread, pyqtSignal, pyqtSlot
from PyQt6.QtGui import (
    QFont,
    QFontMetrics,
)
from PyQt6.QtWidgets import (
    QApplication,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QProgressBar,
    QPushButton,
    QSizePolicy,
    QVBoxLayout,
    QWidget,
)

BASE_COMMAND = [
    "aria2c",
    "--async-dns=false",
    "--connect-timeout=3",
    "--disk-cache=256M",
    "--disable-ipv6=true",
    "--enable-mmap=true",
    "--http-no-cache=true",
    "--max-connection-per-server=16",
    "--min-split-size=1M",
    "--piece-length=1M",
    "--split=32",
    "--timeout=3",
]

url = "http://ipv4.download.thinkbroadband.com/100MB.zip"
UNITS_SIZE = {"B": 1, "KiB": 1 << 10, "MiB": 1 << 20, "GiB": 1 << 30}

DOWNLOAD_PROGRESS = re.compile(
    "(?P<downloaded>\d+(\.\d+)?[KMG]iB)/(?P<total>\d+(\.\d+)?[KMG]iB)"
)

UNITS = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB")
ALIGNMENT = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop

class Font(QFont):
    def __init__(self, size: int = 10) -> None:
        super().__init__()
        self.setFamily("Times New Roman")
        self.setStyleHint(QFont.StyleHint.Times)
        self.setStyleStrategy(QFont.StyleStrategy.PreferAntialias)
        self.setPointSize(size)
        self.setBold(True)
        self.setHintingPreference(QFont.HintingPreference.PreferFullHinting)


FONT = Font()
FONT_RULER = QFontMetrics(FONT)

class Box(QGroupBox):
    def __init__(self) -> None:
        super().__init__()
        self.setAlignment(ALIGNMENT)
        self.setContentsMargins(3, 3, 3, 3)
        self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
        self.vbox = make_vbox(self)


class Button(QPushButton):
    def __init__(self, text: str) -> None:
        super().__init__()
        self.setFont(FONT)
        self.setFixedSize(72, 20)
        self.setText(text)

def make_box(
    box_type: type[QHBoxLayout] | type[QVBoxLayout] | type[QGridLayout],
    parent: QWidget,
    margin: int,
) -> QHBoxLayout | QVBoxLayout | QGridLayout:
    box = box_type(parent) if parent else box_type()
    box.setAlignment(ALIGNMENT)
    box.setContentsMargins(*[margin] * 4)
    return box


def make_vbox(parent: QWidget = None, margin: int = 0) -> QVBoxLayout:
    return make_box(QVBoxLayout, parent, margin)

def make_hbox(parent: QWidget = None, margin: int = 0) -> QHBoxLayout:
    return make_box(QHBoxLayout, parent, margin)



class Label(QLabel):
    def __init__(self, text: str) -> None:
        super().__init__()
        self.setFont(FONT)
        self.set_text(text)

    def autoResize(self) -> None:
        self.Height = FONT_RULER.size(0, self.text()).height()
        self.Width = FONT_RULER.size(0, self.text()).width()
        self.setFixedSize(self.Width + 3, self.Height + 8)

    def set_text(self, text: str) -> None:
        self.setText(text)
        self.autoResize()


class ProgressBar(QProgressBar):
    def __init__(self) -> None:
        super().__init__()
        self.setFont(FONT)
        self.setValue(0)
        self.setFixedSize(1000, 25)


class DownThread(QThread):
    update = pyqtSignal(dict)
    def __init__(self, parent: QWidget, url: str, folder: str) -> None:
        super().__init__(parent)
        self.url = url
        self.folder = folder
        self.line = ""
        self.stats = {}

    def run(self) -> None:
        self.total = 0
        res = requests.head(url)
        if res.status_code == 200 and (total := res.headers.get("Content-Length")):
            self.total = int(total)
        self.process = subprocess.Popen(
            BASE_COMMAND + [f"--dir={self.folder}", self.url],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )
        self.monitor()
        self.quit()

    def monitor(self) -> None:
        self.start = self.elapsed = time.time_ns()
        self.downloaded = 0
        output = self.process.stdout
        self.buffer = ""
        while self.process.poll() is None:
            char = output.read(1)
            if char in (b"\n", b"\r"):
                self.line = self.buffer
                self.buffer = ""
                self.update_stats()
            else:
                self.buffer += char.decode()
        
        self.finish()

    def update_stats(self) -> None:
        if match := DOWNLOAD_PROGRESS.search(self.line):
            current = time.time_ns()
            new, total = map(self.parse_size, match.groupdict().values())
            delta = (current - self.elapsed) / 1e9
            speed = (new - self.downloaded) / delta
            self.stats = {
                "downloaded": new,
                "total": total,
                "speed": speed,
                "elapsed": (current - self.start) / 1e9,
                "eta": ((total - new) / speed) if speed != 0 else 1e309,
            }
            self.elapsed = current
            self.downloaded = new
            self.update.emit(self.stats)

    @staticmethod
    def parse_size(size: str) -> int:
        unit = size[-3:]
        size = size.replace(unit, "")
        return (float if "." in size else int)(size) * UNITS_SIZE[unit]

    def finish(self):
        self.elapsed = (time.time_ns() - self.start) // 1e9
        total = self.total or self.stats["total"]
        self.stats["downloaded"] = total
        self.stats["total"] = total
        self.stats["elapsed"] = self.elapsed
        self.stats["eta"] = 0
        self.stats["speed"] = total / self.elapsed
        self.update.emit(self.stats)

class Underbar(Box):
    def __init__(self):
        super().__init__()
        self.setFixedHeight(256)
        self.progressbar = ProgressBar()
        self.hbox = make_hbox()
        self.hbox.addWidget(self.progressbar)
        self.displays = {}
        for name in ("Downloaded", "Total", "Speed", "Elapsed", "ETA"):
            self.hbox.addWidget(Label(name))
            widget = Label("0")
            self.hbox.addWidget(widget)
            self.displays[name] = widget

        self.vbox.addLayout(self.hbox)
        self.button = Button("Test")
        self.vbox.addWidget(self.button)
        self.button.clicked.connect(self.test)

    def test(self):
        self.progressbar.setValue(0)
        down = DownThread(self, url, "D:/downloads")
        down.update.connect(self.update_displays)
        down.run()

    def update_displays(self, stats):
        self.progressbar.setValue(100 * int(stats["downloaded"] / stats["total"] + 0.5))
        for name, suffix in (("Downloaded", ""), ("Total", ""), ("Speed", "/s")):
            self.displays[name].setText(
                f"{round(stats[name.lower()] / 1048576, 2)}MiB{suffix}"
            )

        self.displays["Elapsed"].setText(f'{round(stats["elapsed"], 2)}s')
        self.displays["ETA"].setText(f'{round(stats["eta"], 2)}s')
        for label in self.displays.values():
            label.autoResize()


if __name__ == "__main__":
    app = QApplication([])
    app.setStyle("Fusion")
    window = Underbar()
    window.show()
    sys.exit(app.exec())

如何解决这个问题?

python subprocess qthread pyqt6
1个回答
0
投票

我已经解决了这个问题。我放弃了使用子进程调用 aria2c 的想法,而是使用 aiohttp + aiofiles 使用 HTTP Range 标头分多个部分下载文件。

然后我使用 qasync 中的 QEventLoop 使 asyncio 与 QThread 一起工作,并且它可以正常工作。

import aiofiles
import asyncio
import sys
from aiohttp import ClientSession
from bisect import bisect
from PyQt6.QtCore import Qt, QThread, pyqtSignal, pyqtSlot
from PyQt6.QtGui import (
    QFont,
    QFontMetrics,
)
from PyQt6.QtWidgets import (
    QApplication,
    QGridLayout,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QProgressBar,
    QPushButton,
    QSizePolicy,
    QVBoxLayout,
    QWidget,
)
from qasync import QEventLoop
from tqdm import tqdm

CHUNK = 524288
url = "http://ipv4.download.thinkbroadband.com/100MB.zip"
UNITS = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB")
UNIT_SIZES = [1 << (i * 10) for i in range(11)]
ALIGNMENT = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop


def to_time(n: float) -> str:
    n = int(n + 0.5)
    segments = []
    for _ in (0, 1):
        n, d = divmod(n, 60)
        segments.insert(0, d)

    if n:
        segments.insert(0, n)

    return ":".join(map(str, segments))


def format_size(size: int) -> str:
    i = bisect(UNIT_SIZES, size) - 1
    return f"{round(size/UNIT_SIZES[i], 3)}{UNITS[i]}"


class Downloader(QThread):
    update = pyqtSignal()
    refresh = pyqtSignal()

    def __init__(self, url: str, filepath: str, links: int = 16) -> None:
        super().__init__()
        self.url = url
        self.filepath = filepath
        self.links = links
        self.update.connect(self.update_values)

    async def preprocess(self, session: ClientSession) -> None:
        resp = await session.head(self.url)
        self.total = int(resp.headers["Content-Length"])
        self.progress = tqdm(
            total=self.total, unit_scale=True, unit_divisor=1024, unit="B"
        )

    async def start_download(self) -> None:
        async with ClientSession(
            headers={
                "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0"
            }
        ) as session:
            await self.preprocess(session)
            self.chunk = self.total // self.links
            ends = range(0, self.total, self.chunk)[: self.links]
            self.ranges = [
                *((start, end - 1) for start, end in zip(ends, ends[1:])),
                (ends[-1], self.total - 1),
            ]
            await asyncio.gather(
                *(self.download_worker(i, session) for i in range(self.links))
            )

    async def download_worker(self, index: int, session: ClientSession) -> None:
        async with aiofiles.open(self.filepath, "wb") as file:
            start, end = self.ranges[index]
            await file.seek(start)
            async with session.get(
                url=self.url,
                headers={"Range": f"bytes={start}-{end}"},
            ) as resp:
                async for chunk in resp.content.iter_chunked(CHUNK):
                    self.progress.update(len(chunk))
                    self.update.emit()
                    await file.write(chunk)

    def update_values(self) -> None:
        d = self.progress.format_dict
        self.stats = {
            "Downloaded": d["n"],
            "Total": d["total"],
            "Elapsed": d["elapsed"],
            "Speed": d["rate"],
            "ETA": (d["total"] - d["n"]) / d["rate"],
        }
        self.refresh.emit()

    def run(self) -> None:
        loop = QEventLoop(self)
        asyncio.set_event_loop(loop)
        loop.run_until_complete(self.start_download())


class Font(QFont):
    def __init__(self, size: int = 10) -> None:
        super().__init__()
        self.setFamily("Times New Roman")
        self.setStyleHint(QFont.StyleHint.Times)
        self.setStyleStrategy(QFont.StyleStrategy.PreferAntialias)
        self.setPointSize(size)
        self.setBold(True)
        self.setHintingPreference(QFont.HintingPreference.PreferFullHinting)


FONT = Font()
FONT_RULER = QFontMetrics(FONT)


class Box(QGroupBox):
    def __init__(self) -> None:
        super().__init__()
        self.setAlignment(ALIGNMENT)
        self.setContentsMargins(3, 3, 3, 3)
        self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
        self.vbox = make_vbox(self)


class Button(QPushButton):
    def __init__(self, text: str) -> None:
        super().__init__()
        self.setFont(FONT)
        self.setFixedSize(72, 20)
        self.setText(text)


def make_box(
    box_type: type[QHBoxLayout] | type[QVBoxLayout] | type[QGridLayout],
    parent: QWidget,
    margin: int,
) -> QHBoxLayout | QVBoxLayout | QGridLayout:
    box = box_type(parent) if parent else box_type()
    box.setAlignment(ALIGNMENT)
    box.setContentsMargins(*[margin] * 4)
    return box


def make_vbox(parent: QWidget = None, margin: int = 0) -> QVBoxLayout:
    return make_box(QVBoxLayout, parent, margin)


def make_hbox(parent: QWidget = None, margin: int = 0) -> QHBoxLayout:
    return make_box(QHBoxLayout, parent, margin)


class Label(QLabel):
    def __init__(self, text: str) -> None:
        super().__init__()
        self.setFont(FONT)
        self.set_text(text)

    def autoResize(self) -> None:
        self.Height = FONT_RULER.size(0, self.text()).height()
        self.Width = FONT_RULER.size(0, self.text()).width()
        self.setFixedSize(self.Width + 3, self.Height + 8)

    def set_text(self, text: str) -> None:
        self.setText(text)
        self.autoResize()


class ProgressBar(QProgressBar):
    def __init__(self) -> None:
        super().__init__()
        self.setFont(FONT)
        self.setValue(0)
        self.setFixedSize(1000, 25)


class Underbar(Box):
    def __init__(self):
        super().__init__()
        self.setFixedHeight(256)
        self.progressbar = ProgressBar()
        self.hbox = make_hbox()
        self.hbox.addWidget(self.progressbar)
        self.displays = {}
        for name in ("Downloaded", "Total", "Speed", "Elapsed", "ETA"):
            self.hbox.addWidget(Label(name))
            widget = Label("0")
            self.hbox.addWidget(widget)
            self.displays[name] = widget

        self.vbox.addLayout(self.hbox)
        self.button = Button("Test")
        self.vbox.addWidget(self.button)
        self.button.clicked.connect(self.test)

    def test(self):
        self.progressbar.setValue(0)
        self.down = Downloader(url, "D:/speedtest/100MB.zip")
        self.down.refresh.connect(self.update_values)
        self.down.run()

    def update_values(self):
        stats = self.down.stats
        self.displays["Downloaded"].setText(format_size(stats["Downloaded"]))
        self.displays["Total"].setText(format_size(stats["Total"]))
        self.displays["Speed"].setText(format_size(stats["Speed"]) + "/s")
        self.displays["Elapsed"].setText(to_time(stats["Elapsed"]))
        self.displays["ETA"].setText(to_time(stats["ETA"]))
        self.progressbar.setValue(int(stats["Downloaded"] / stats["Total"] * 100 + 0.5))
        for label in self.displays.values():
            label.autoResize()


if __name__ == "__main__":
    app = QApplication([])
    app.setStyle("Fusion")
    window = Underbar()
    window.show()
    sys.exit(app.exec())
© www.soinside.com 2019 - 2024. All rights reserved.