From 42591d244b17001a777a8e3181aa39ad2073ea7b Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:01:54 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E7=A8=8B=E5=BA=8F=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=8E=A5=E5=85=A5=20ZZZ=20=E6=A1=86=E6=9E=B6=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步 ZenlessZoneZero-OneDragon 的下载队列(a2789666e):CommonDownloader 多源/状态回调、 http_utils、DownloadQueueService、下载队列弹窗。程序自更新改为入队下载, 设置页可打开下载队列弹窗查看进度、取消。EnvConfig 补充单源兼容方法。 --- src/one_dragon/base/web/common_downloader.py | 150 ++++-- src/one_dragon/base/web/zip_downloader.py | 28 +- src/one_dragon/envs/env_config.py | 15 + src/one_dragon/utils/http_utils.py | 139 ++++- .../services/download_queue_service.py | 389 ++++++++++++++ .../widgets/download_queue_dialog.py | 476 ++++++++++++++++++ src/one_dragon_qt/widgets/fast_scroll_area.py | 39 ++ .../context/script_chainer_context.py | 20 + .../gui/page/editor_setting_interface.py | 80 ++- .../services/github_update_service.py | 93 ++-- 10 files changed, 1271 insertions(+), 158 deletions(-) create mode 100644 src/one_dragon_qt/services/download_queue_service.py create mode 100644 src/one_dragon_qt/widgets/download_queue_dialog.py create mode 100644 src/one_dragon_qt/widgets/fast_scroll_area.py diff --git a/src/one_dragon/base/web/common_downloader.py b/src/one_dragon/base/web/common_downloader.py index 32e401e..a59b9f4 100644 --- a/src/one_dragon/base/web/common_downloader.py +++ b/src/one_dragon/base/web/common_downloader.py @@ -1,9 +1,27 @@ import os from collections.abc import Callable +from dataclasses import dataclass from one_dragon.utils import http_utils from one_dragon.utils.log_utils import log +# 使用 ghproxy 代理的源 +GITHUB_SOURCE_ID: str = 'github' +AUTO_FALLBACK_MIN_BYTES_PER_SECOND: float = 100 * 1024 + + +@dataclass(frozen=True, slots=True) +class ResourceDownloadProgress: + """资源下载的结构化进度。""" + + source_id: str = '' + phase: str = 'waiting' + downloaded_bytes: int = 0 + total_bytes: int = 0 + bytes_per_second: float = 0 + progress: float = 0 + message: str = '' + class CommonDownloaderParam: @@ -11,29 +29,23 @@ def __init__( self, save_file_path: str, save_file_name: str, - github_release_download_url: str | None = None, - gitee_release_download_url: str | None = None, - mirror_chan_download_url: str | None = None, + download_urls: dict[str, str] | None = None, check_existed_list: list[str] | None = None, unzip_dir_path: str | None = None, ): """ - 一个通用下载器 可提供3个下载源 并检查文件是否存在 如果存在则不进行下载 + 一个通用下载器 可提供多个下载源 并检查文件是否存在 如果存在则不进行下载 Args: save_file_path (str): 文件保存的路径 save_file_name (str): 文件保存的名称 - github_release_download_url (Optional[str], optional): Github Release下载地址. Defaults to None. - gitee_release_download_url (Optional[str], optional): Gitee Release下载地址. Defaults to None. - mirror_chan_download_url (Optional[str], optional): Mirror酱下载地址. Defaults to None. + download_urls (Optional[dict[str, str]], optional): 源ID到下载地址的映射. Defaults to None. check_existed_list (Optional[list[str]], optional): 需要检查文件是否存在的列表 完整路径的列表. Defaults to None. unzip_dir_path (Optional[str], optional): 解压目录路径,如果为None则解压到save_file_path. Defaults to None. """ self.save_file_path: str = save_file_path self.save_file_name: str = save_file_name - self.github_release_download_url: str | None = github_release_download_url - self.gitee_release_download_url: str | None = gitee_release_download_url - self.mirror_chan_download_url: str | None = mirror_chan_download_url + self.download_urls: dict[str, str] = {} if download_urls is None else download_urls self.check_existed_list: list[str] = [] if check_existed_list is None else check_existed_list self.unzip_dir_path: str | None = unzip_dir_path @@ -45,7 +57,7 @@ def __init__( param: CommonDownloaderParam, ) -> None: """ - 一个通用下载器 可提供3个下载源 并检查文件是否存在 如果存在则不进行下载 + 一个通用下载器 可提供多个下载源 按候选顺序尝试 并检查文件是否存在 如果存在则不进行下载 Args: param (CommonDownloaderParam): 下载参数 @@ -54,39 +66,109 @@ def __init__( def download( self, - download_by_github: bool = True, - download_by_gitee: bool = False, - download_by_mirror_chan: bool = False, + source_order: list[str] | None = None, proxy_url: str | None = None, ghproxy_url: str | None = None, skip_if_existed: bool = True, progress_signal: dict[str, str | None] | None = None, - progress_callback: Callable[[float, str], None] | None = None + progress_callback: Callable[[float, str], None] | None = None, + status_callback: Callable[[ResourceDownloadProgress], None] | None = None, + on_source_success: Callable[[str], None] | None = None, + on_source_failure: Callable[[str], None] | None = None, + fallback_on_slow: bool = False, ) -> bool: + """ + 按候选源顺序依次尝试下载 直到成功 + + Args: + source_order: 候选源ID顺序 None或与下载地址无交集时使用参数中的地址顺序 + proxy_url: 个人代理地址 + ghproxy_url: ghproxy 代理地址 只对 github 源生效 + skip_if_existed: 文件已存在时是否跳过下载 + progress_signal: 进度控制信号 signal=cancel 时取消下载 + progress_callback: 进度回调 + status_callback: 结构化进度回调 + on_source_success: 某个源下载成功后的回调 入参为源ID + on_source_failure: 某个源下载失败后的回调 入参为源ID + fallback_on_slow: 是否在候选源持续低速时尝试下一个源 + + Returns: + bool: 是否下载成功 + """ if skip_if_existed and self.is_file_existed(): return True - download_url: str = '' - if download_by_github and self.param.github_release_download_url is not None: - if ghproxy_url is not None: - download_url=f'{ghproxy_url}/{self.param.github_release_download_url}' - else: - download_url = self.param.github_release_download_url - elif download_by_gitee and self.param.gitee_release_download_url is not None: - download_url = self.param.gitee_release_download_url - elif download_by_mirror_chan and self.param.mirror_chan_download_url is not None: - download_url = self.param.mirror_chan_download_url - - if download_url == '': - log.error('没有指定下载方法或对应的下载地址') + candidates: list[str] = [] + if source_order is not None: + candidates = [i for i in source_order if i in self.param.download_urls] + if len(candidates) == 0: + candidates = list(self.param.download_urls.keys()) + + if len(candidates) == 0: + log.error('没有可用的下载源') + if status_callback is not None: + status_callback(ResourceDownloadProgress(phase='failed', message='没有可用的下载源')) return False - return http_utils.download_file( - download_url=download_url, - save_file_path=os.path.join(self.param.save_file_path, self.param.save_file_name), - proxy=proxy_url, - progress_signal=progress_signal, - progress_callback=progress_callback) + save_file_path = os.path.join(self.param.save_file_path, self.param.save_file_name) + for index, source_id in enumerate(candidates): + download_url = self.param.download_urls[source_id] + if source_id == GITHUB_SOURCE_ID and ghproxy_url is not None: + proxy_prefix = ghproxy_url.rstrip('/') + if proxy_prefix: + download_url = f'{proxy_prefix}/{download_url}' + + log.info(f'尝试从 {source_id} 下载 {self.param.save_file_name}') + if status_callback is not None: + status_callback( + ResourceDownloadProgress( + source_id=source_id, + phase='connecting', + message='正在连接', + ) + ) + success = http_utils.download_file( + download_url=download_url, + save_file_path=save_file_path, + proxy=proxy_url, + min_bytes_per_second=( + AUTO_FALLBACK_MIN_BYTES_PER_SECOND + if fallback_on_slow and index < len(candidates) - 1 + else None + ), + progress_signal=progress_signal, + progress_callback=progress_callback, + status_callback=( + lambda progress, current_source=source_id: status_callback( + ResourceDownloadProgress( + source_id=current_source, + phase=progress.phase, + downloaded_bytes=progress.downloaded_bytes, + total_bytes=progress.total_bytes, + bytes_per_second=progress.bytes_per_second, + progress=progress.progress, + message=progress.message, + ) + ) + if status_callback is not None + else None + ), + ) + + if success: + if on_source_success is not None: + on_source_success(source_id) + return True + + if progress_signal is not None and progress_signal.get('signal') == 'cancel': + # 用户主动取消 不再尝试其他源 + return False + + if on_source_failure is not None: + on_source_failure(source_id) + log.warning(f'从 {source_id} 下载失败 尝试下一个源') + + return False def is_file_existed(self) -> bool: """ diff --git a/src/one_dragon/base/web/zip_downloader.py b/src/one_dragon/base/web/zip_downloader.py index cf2e915..cb0cc57 100644 --- a/src/one_dragon/base/web/zip_downloader.py +++ b/src/one_dragon/base/web/zip_downloader.py @@ -1,9 +1,11 @@ import os from collections.abc import Callable +from pathlib import Path from one_dragon.base.web.common_downloader import ( CommonDownloader, CommonDownloaderParam, + ResourceDownloadProgress, ) from one_dragon.utils import file_utils from one_dragon.utils.log_utils import log @@ -28,31 +30,37 @@ def __init__( def download( self, - download_by_github: bool = True, - download_by_gitee: bool = False, - download_by_mirror_chan: bool = False, + source_order: list[str] | None = None, proxy_url: str | None = None, ghproxy_url: str | None = None, skip_if_existed: bool = True, progress_signal: dict[str, str | None] | None = None, - progress_callback: Callable[[float, str], None] | None = None + progress_callback: Callable[[float, str], None] | None = None, + status_callback: Callable[[ResourceDownloadProgress], None] | None = None, + on_source_success: Callable[[str], None] | None = None, + on_source_failure: Callable[[str], None] | None = None, + fallback_on_slow: bool = False, ) -> bool: for i in range(2): download_result = CommonDownloader.download( self, - download_by_github=download_by_github, - download_by_gitee=download_by_gitee, - download_by_mirror_chan=download_by_mirror_chan, + source_order=source_order, proxy_url=proxy_url, ghproxy_url=ghproxy_url, skip_if_existed=skip_if_existed if i == 0 else False, # 第2次重试时必定重新下载 progress_signal=progress_signal, progress_callback=progress_callback, + status_callback=status_callback, + on_source_success=on_source_success, + on_source_failure=on_source_failure, + fallback_on_slow=fallback_on_slow, ) if not download_result: return download_result + if status_callback is not None: + status_callback(ResourceDownloadProgress(phase='extracting', progress=1, message='正在解压')) unzip_result = self.unzip() if unzip_result: break @@ -61,7 +69,11 @@ def download( continue # 解压有可能失败 最后再判断一次解压产物是否已经存在 - return CommonDownloader.is_file_existed(self) + success = CommonDownloader.is_file_existed(self) + if not success: + archive = Path(self.param.save_file_path) / self.param.save_file_name + archive.unlink(missing_ok=True) + return success def unzip(self) -> bool: """ diff --git a/src/one_dragon/envs/env_config.py b/src/one_dragon/envs/env_config.py index fe259a5..efb1867 100644 --- a/src/one_dragon/envs/env_config.py +++ b/src/one_dragon/envs/env_config.py @@ -182,6 +182,21 @@ def force_update(self, new_value: bool) -> None: """ self.update('force_update', new_value) + def get_resource_source_order(self) -> list[str]: + """资源下载的候选源顺序 千机链只从 GitHub Release 下载。""" + return ['github'] + + @property + def is_resource_source_auto(self) -> bool: + """是否由下载器自动选择资源源 千机链只有一个源 无需自动回退。""" + return False + + def mark_resource_source_success(self, source_id: str) -> None: + """记录资源下载成功的源 千机链只有一个源 无需记录。""" + + def mark_resource_source_failure(self, source_id: str) -> None: + """记录资源下载失败的源 千机链只有一个源 无需记录。""" + @property def auto_update(self) -> bool: """ diff --git a/src/one_dragon/utils/http_utils.py b/src/one_dragon/utils/http_utils.py index 94bb222..4da1b4b 100644 --- a/src/one_dragon/utils/http_utils.py +++ b/src/one_dragon/utils/http_utils.py @@ -2,24 +2,49 @@ import time import urllib.parse import urllib.request +from collections import deque from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path from one_dragon.utils.i18_utils import gt from one_dragon.utils.log_utils import log -def download_file(download_url: str, save_file_path: str, - proxy: str | None = None, progress_signal: dict[str, str | None] | None = None, - progress_callback: Callable[[float, str], None] | None = None) -> bool: - """ - 下载文件 - :param download_url: 下载的url - :param save_file_path: 保存的文件路径,包含文件名 - :param proxy: 使用的代理地址 - :param progress_signal: 进度信号字典,当字典中 'signal' 键的值为 'cancel' 时会取消下载 - :param progress_callback: 下载进度的回调,进度发生改变时,通过该方法通知调用方。 - :return: 是否下载成功 +@dataclass(frozen=True, slots=True) +class HttpDownloadProgress: + """HTTP 下载的结构化进度。""" + + phase: str + downloaded_bytes: int = 0 + total_bytes: int = 0 + bytes_per_second: float = 0 + progress: float = 0 + message: str = '' + + +def download_file( + download_url: str, + save_file_path: str, + proxy: str | None = None, + min_bytes_per_second: float | None = None, + progress_signal: dict[str, str | None] | None = None, + progress_callback: Callable[[float, str], None] | None = None, + status_callback: Callable[[HttpDownloadProgress], None] | None = None, +) -> bool: + """下载文件。 + + Args: + download_url: 下载地址。 + save_file_path: 完整保存路径。 + proxy: 个人代理地址。 + min_bytes_per_second: 持续低于该速度时中止;None 表示不限制。 + progress_signal: 兼容旧调用方的取消信号。 + progress_callback: 兼容旧调用方的进度回调。 + status_callback: 结构化下载进度回调。 + + Returns: + 下载是否成功。 """ proxy_handler = ( urllib.request.ProxyHandler({'http': proxy, 'https': proxy}) @@ -27,17 +52,23 @@ def download_file(download_url: str, save_file_path: str, ) opener = urllib.request.build_opener(proxy_handler) - last_log_time = time.time() + started_at = time.monotonic() + last_log_time = started_at + last_status_time = started_at + last_status_bytes = 0 + download_started_at: float | None = None + speed_samples: deque[tuple[float, int]] = deque() save_path = Path(save_file_path) save_path.parent.mkdir(parents=True, exist_ok=True) temp_path: Path | None = None - def log_download_progress(downloaded_bytes: int, total_size: int) -> None: - nonlocal last_log_time - now = time.time() - if now - last_log_time < 1: + def report_download_progress(downloaded_bytes: int, total_size: int) -> None: + """上报下载进度并限制界面刷新频率。""" + nonlocal last_log_time, last_status_time, last_status_bytes + now = time.monotonic() + status_elapsed = now - last_status_time + if status_elapsed < 0.25 and downloaded_bytes != total_size: return - last_log_time = now downloaded_mb = downloaded_bytes / 1024.0 / 1024.0 if total_size > 0: @@ -48,22 +79,70 @@ def log_download_progress(downloaded_bytes: int, total_size: int) -> None: progress = 0 msg = f"{gt('正在下载')} {downloaded_mb:.2f} MB" - log.info(msg) + speed = ( + (downloaded_bytes - last_status_bytes) / status_elapsed + if status_elapsed > 0 + else 0 + ) + last_status_time = now + last_status_bytes = downloaded_bytes + speed_samples.append((now, downloaded_bytes)) + while ( + speed_samples + and speed_samples[0][0] < now - 4 + ): + speed_samples.popleft() + + if ( + min_bytes_per_second is not None + and download_started_at is not None + and now - download_started_at >= 10 + and len(speed_samples) >= 2 + ): + sample_started_at, sample_started_bytes = speed_samples[0] + sample_elapsed = now - sample_started_at + if sample_elapsed >= 3: + recent_speed = ( + downloaded_bytes - sample_started_bytes + ) / sample_elapsed + if recent_speed < min_bytes_per_second: + raise DownloadTooSlowError( + f'下载速度持续过低:{recent_speed / 1024:.0f} KB/s' + ) + + if now - last_log_time >= 1: + last_log_time = now + log.info(msg) if progress_callback is not None: progress_callback(progress, msg) + if status_callback is not None: + status_callback( + HttpDownloadProgress( + phase='downloading', + downloaded_bytes=downloaded_bytes, + total_bytes=total_size, + bytes_per_second=speed, + progress=progress, + message=msg, + ) + ) try: msg = f"{gt('开始下载')} {download_url}" log.info(msg) if progress_callback is not None: progress_callback(0, msg) + if status_callback is not None: + status_callback(HttpDownloadProgress(phase='connecting', message=gt('正在连接'))) url = urllib.parse.urlparse(download_url) if url.scheme not in ('http', 'https'): raise ValueError(f"不支持的下载协议:{download_url}") request = urllib.request.Request(download_url) - with opener.open(request, timeout=60) as response: + # 读超时保持较短,避免网络停滞时取消请求长时间无响应。 + with opener.open(request, timeout=5) as response: + download_started_at = time.monotonic() total_size = int(response.headers.get('Content-Length', '0') or 0) downloaded_bytes = 0 chunk_size = 1024 * 64 @@ -80,7 +159,7 @@ def log_download_progress(downloaded_bytes: int, total_size: int) -> None: file.write(chunk) downloaded_bytes += len(chunk) - log_download_progress(downloaded_bytes, total_size) + report_download_progress(downloaded_bytes, total_size) if total_size > 0 and downloaded_bytes != total_size: raise DownloadIncompleteError( @@ -94,6 +173,18 @@ def log_download_progress(downloaded_bytes: int, total_size: int) -> None: log.info(msg) if progress_callback is not None: progress_callback(1, msg) + if status_callback is not None: + elapsed = max(time.monotonic() - started_at, 0.001) + status_callback( + HttpDownloadProgress( + phase='downloaded', + downloaded_bytes=downloaded_bytes, + total_bytes=total_size, + bytes_per_second=downloaded_bytes / elapsed, + progress=1, + message=gt('下载完成'), + ) + ) return True except DownloadCancelledError: if temp_path is not None: @@ -102,6 +193,8 @@ def log_download_progress(downloaded_bytes: int, total_size: int) -> None: log.info(msg) if progress_callback is not None: progress_callback(0, msg) + if status_callback is not None: + status_callback(HttpDownloadProgress(phase='cancelled', message=msg)) return False except Exception as e: if temp_path is not None: @@ -109,6 +202,8 @@ def log_download_progress(downloaded_bytes: int, total_size: int) -> None: msg = f"{gt('下载失败')} {e}" if progress_callback is not None: progress_callback(0, msg) + if status_callback is not None: + status_callback(HttpDownloadProgress(phase='failed', message=msg)) log.error(msg, exc_info=True) return False @@ -119,3 +214,7 @@ class DownloadCancelledError(Exception): class DownloadIncompleteError(Exception): pass + + +class DownloadTooSlowError(Exception): + pass diff --git a/src/one_dragon_qt/services/download_queue_service.py b/src/one_dragon_qt/services/download_queue_service.py new file mode 100644 index 0000000..415986c --- /dev/null +++ b/src/one_dragon_qt/services/download_queue_service.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import time +from collections.abc import Callable +from dataclasses import dataclass, field +from enum import StrEnum +from typing import TYPE_CHECKING + +from PySide6.QtCore import QObject, QThread, Signal + +from one_dragon.base.web.common_downloader import ( + CommonDownloader, + ResourceDownloadProgress, +) +from one_dragon.utils.log_utils import log + +if TYPE_CHECKING: + from one_dragon.base.operation.one_dragon_env_context import OneDragonEnvContext + + +class ResourceDownloadTaskState(StrEnum): + """下载队列任务状态。""" + + WAITING = 'waiting' + DOWNLOADING = 'downloading' + EXTRACTING = 'extracting' + APPLYING = 'applying' + CANCELLING = 'cancelling' + CANCELLED = 'cancelled' + SUCCEEDED = 'succeeded' + FAILED = 'failed' + + +@dataclass(slots=True) +class ResourceDownloadSpec: + """可加入下载队列的一项资源。""" + + resource_id: str + resource_type: str + title: str + current_version: str + target_version: str + downloader_factory: Callable[[], CommonDownloader] + note: str = '' + source_order: list[str] | None = None + before_download: Callable[[], None] | None = None + after_download: Callable[[bool], None] | None = None + + @property + def task_key(self) -> str: + """返回队列去重键。""" + return f'{self.resource_id}:{self.target_version}' + + +@dataclass(slots=True) +class ResourceDownloadTask: + """下载队列中的任务及运行状态。""" + + spec: ResourceDownloadSpec + state: ResourceDownloadTaskState = ResourceDownloadTaskState.WAITING + queued_at: float = field(default_factory=time.time) + progress: ResourceDownloadProgress = field(default_factory=ResourceDownloadProgress) + error_message: str = '' + progress_signal: dict[str, str | None] = field(default_factory=lambda: {'signal': None}) + + @property + def task_key(self) -> str: + """返回任务去重键。""" + return self.spec.task_key + + +class DownloadTaskWorker(QThread): + """执行单个下载任务的工作线程。""" + + progress_changed = Signal(object) + task_finished = Signal(bool, bool, str) + + def __init__(self, ctx: OneDragonEnvContext, task: ResourceDownloadTask) -> None: + """创建任务线程。 + + Args: + ctx: 环境上下文。 + task: 要执行的下载任务。 + """ + super().__init__() + self.ctx: OneDragonEnvContext = ctx + self.task: ResourceDownloadTask = task + + def run(self) -> None: + """执行下载并上报最终结果。""" + success = False + error_message = '' + try: + if self.task.spec.before_download is not None: + self.task.spec.before_download() + env_config = self.ctx.env_config + downloader = self.task.spec.downloader_factory() + success = downloader.download( + source_order=self.task.spec.source_order or env_config.get_resource_source_order(), + proxy_url=env_config.personal_proxy if env_config.is_personal_proxy else None, + ghproxy_url=env_config.gh_proxy_url if env_config.is_gh_proxy else None, + skip_if_existed=False, + progress_signal=self.task.progress_signal, + status_callback=self.progress_changed.emit, + on_source_success=env_config.mark_resource_source_success, + on_source_failure=env_config.mark_resource_source_failure, + fallback_on_slow=env_config.is_resource_source_auto, + ) + except Exception as exc: + error_message = str(exc) + log.error('下载队列任务失败', exc_info=True) + finally: + if self.task.spec.after_download is not None: + try: + if success: + self.progress_changed.emit( + ResourceDownloadProgress( + phase='applying', + progress=1, + message='正在应用更新', + ) + ) + self.task.spec.after_download(success) + except Exception as exc: + success = False + error_message = str(exc) + log.error('下载任务后处理失败', exc_info=True) + + cancelled = self.task.progress_signal.get('signal') == 'cancel' + self.task_finished.emit(success, cancelled, error_message) + + def cancel(self) -> None: + """请求取消当前下载。""" + self.task.progress_signal['signal'] = 'cancel' + + +class DownloadQueueService(QObject): + """进程内单线程资源下载队列。""" + + task_added = Signal(object) + task_updated = Signal(object) + task_removed = Signal(str) + queue_updated = Signal() + + def __init__(self, ctx: OneDragonEnvContext, parent: QObject | None = None) -> None: + """创建下载队列。 + + Args: + ctx: 环境上下文。 + parent: Qt 父对象。 + """ + super().__init__(parent) + self.ctx: OneDragonEnvContext = ctx + self.tasks: list[ResourceDownloadTask] = [] + self._worker: DownloadTaskWorker | None = None + self._current_task: ResourceDownloadTask | None = None + self._worker_result: tuple[bool, bool, str] | None = None + + def enqueue(self, spec: ResourceDownloadSpec, priority: bool = False) -> ResourceDownloadTask: + """添加任务,重复任务直接返回已有实例。 + + Args: + spec: 资源规格。 + priority: 是否插入当前任务之后。 + + Returns: + 新建或已有的任务。 + """ + existing = self.get_task(spec.task_key) + if existing is not None: + return existing + + task = ResourceDownloadTask(spec=spec) + if priority and self._current_task in self.tasks: + current_index = self.tasks.index(self._current_task) + self.tasks.insert(current_index + 1, task) + else: + self.tasks.append(task) + self.task_added.emit(task) + self.queue_updated.emit() + self._start_next() + return task + + def get_task(self, task_key: str) -> ResourceDownloadTask | None: + """按去重键查找任务。""" + return next((task for task in self.tasks if task.task_key == task_key), None) + + def cancel(self, task_key: str) -> None: + """取消运行任务或移除等待任务。""" + task = self.get_task(task_key) + if task is None: + return + if task.state == ResourceDownloadTaskState.WAITING: + task.state = ResourceDownloadTaskState.CANCELLED + task.progress = ResourceDownloadProgress(phase='cancelled', message='已取消') + self.task_updated.emit(task) + self.queue_updated.emit() + return + if task is self._current_task and self._worker is not None: + task.state = ResourceDownloadTaskState.CANCELLING + task.progress = ResourceDownloadProgress( + source_id=task.progress.source_id, + phase='cancelling', + downloaded_bytes=task.progress.downloaded_bytes, + total_bytes=task.progress.total_bytes, + bytes_per_second=task.progress.bytes_per_second, + progress=task.progress.progress, + message='正在取消', + ) + self._worker.cancel() + self.task_updated.emit(task) + self.queue_updated.emit() + + def cancel_all(self) -> None: + """取消所有等待或运行任务。""" + for task in list(self.tasks): + if task.state in ( + ResourceDownloadTaskState.WAITING, + ResourceDownloadTaskState.DOWNLOADING, + ResourceDownloadTaskState.EXTRACTING, + ResourceDownloadTaskState.APPLYING, + ): + self.cancel(task.task_key) + + def retry(self, task_key: str) -> None: + """重新排队一个失败或已取消任务。""" + task = self.get_task(task_key) + if task is None or task.state not in ( + ResourceDownloadTaskState.FAILED, + ResourceDownloadTaskState.CANCELLED, + ): + return + task.state = ResourceDownloadTaskState.WAITING + task.progress = ResourceDownloadProgress() + task.error_message = '' + task.progress_signal = {'signal': None} + if task in self.tasks: + self.tasks.remove(task) + insert_index = ( + self.tasks.index(self._current_task) + 1 + if self._current_task in self.tasks + else 0 + ) + self.tasks.insert(insert_index, task) + self.task_updated.emit(task) + self.queue_updated.emit() + self._start_next() + + def remove(self, task_key: str) -> None: + """移除非运行状态任务。""" + task = self.get_task(task_key) + if task is None or task is self._current_task: + return + self.tasks.remove(task) + self.task_removed.emit(task_key) + self.queue_updated.emit() + + def clear_finished(self) -> None: + """清除成功和已取消任务。""" + removable = [ + task for task in self.tasks + if task is not self._current_task + and task.state in ( + ResourceDownloadTaskState.SUCCEEDED, + ResourceDownloadTaskState.CANCELLED, + ) + ] + for task in removable: + self.tasks.remove(task) + self.task_removed.emit(task.task_key) + if removable: + self.queue_updated.emit() + + def has_active_tasks(self) -> bool: + """返回是否存在等待或运行任务。""" + return any( + task.state in ( + ResourceDownloadTaskState.WAITING, + ResourceDownloadTaskState.DOWNLOADING, + ResourceDownloadTaskState.EXTRACTING, + ResourceDownloadTaskState.APPLYING, + ResourceDownloadTaskState.CANCELLING, + ) + for task in self.tasks + ) + + def active_count(self) -> int: + """返回等待和运行任务数量。""" + return sum( + task.state in ( + ResourceDownloadTaskState.WAITING, + ResourceDownloadTaskState.DOWNLOADING, + ResourceDownloadTaskState.EXTRACTING, + ResourceDownloadTaskState.APPLYING, + ResourceDownloadTaskState.CANCELLING, + ) + for task in self.tasks + ) + + def failed_count(self) -> int: + """返回失败任务数量。""" + return sum(task.state == ResourceDownloadTaskState.FAILED for task in self.tasks) + + def _start_next(self) -> None: + """没有运行任务时启动下一项。""" + if self._worker is not None and self._worker.isRunning(): + return + task = next( + (item for item in self.tasks if item.state == ResourceDownloadTaskState.WAITING), + None, + ) + if task is None: + self._worker = None + self._current_task = None + self.queue_updated.emit() + return + + self._current_task = task + task.state = ResourceDownloadTaskState.DOWNLOADING + task.progress_signal = {'signal': None} + self.task_updated.emit(task) + self.queue_updated.emit() + + worker = DownloadTaskWorker(self.ctx, task) + worker.progress_changed.connect( + lambda progress, bound_task=task: self._on_progress_changed(bound_task, progress) + ) + worker.task_finished.connect(self._store_worker_result) + worker.finished.connect(self._on_worker_finished) + worker.finished.connect(worker.deleteLater) + self._worker = worker + self._worker_result = None + worker.start() + + def _on_progress_changed( + self, + task: ResourceDownloadTask, + progress: ResourceDownloadProgress, + ) -> None: + """保存指定任务的进度。""" + if task is not self._current_task: + return + task.progress = progress + if progress.phase == 'extracting': + task.state = ResourceDownloadTaskState.EXTRACTING + elif progress.phase == 'applying': + task.state = ResourceDownloadTaskState.APPLYING + elif task.state != ResourceDownloadTaskState.CANCELLING: + task.state = ResourceDownloadTaskState.DOWNLOADING + self.task_updated.emit(task) + + def _store_worker_result(self, success: bool, cancelled: bool, error_message: str) -> None: + """保存工作线程结果,等线程真正结束后再启动下一项。""" + self._worker_result = (success, cancelled, error_message) + + def _on_worker_finished(self) -> None: + """收口当前任务并启动下一项。""" + task = self._current_task + if task is None: + return + success, cancelled, error_message = self._worker_result or ( + False, + task.progress_signal.get('signal') == 'cancel', + '下载线程异常结束', + ) + task.error_message = error_message + if success: + task.state = ResourceDownloadTaskState.SUCCEEDED + task.progress = ResourceDownloadProgress(phase='succeeded', progress=1, message='下载完成') + elif cancelled: + task.state = ResourceDownloadTaskState.CANCELLED + task.progress = ResourceDownloadProgress(phase='cancelled', message='已取消') + else: + task.state = ResourceDownloadTaskState.FAILED + message = error_message or task.progress.message or '下载失败' + task.progress = ResourceDownloadProgress( + source_id=task.progress.source_id, + phase='failed', + downloaded_bytes=task.progress.downloaded_bytes, + total_bytes=task.progress.total_bytes, + progress=task.progress.progress, + message=message, + ) + self.task_updated.emit(task) + self.queue_updated.emit() + self._worker = None + self._current_task = None + self._worker_result = None + self._start_next() diff --git a/src/one_dragon_qt/widgets/download_queue_dialog.py b/src/one_dragon_qt/widgets/download_queue_dialog.py new file mode 100644 index 0000000..c044740 --- /dev/null +++ b/src/one_dragon_qt/widgets/download_queue_dialog.py @@ -0,0 +1,476 @@ +from __future__ import annotations + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QResizeEvent +from PySide6.QtWidgets import QHBoxLayout, QSizePolicy, QVBoxLayout, QWidget +from qfluentwidgets import ( + BodyLabel, + CaptionLabel, + FluentIcon, + HorizontalSeparator, + IndeterminateProgressBar, + MessageBoxBase, + ProgressBar, + StrongBodyLabel, + SubtitleLabel, + ToolTipFilter, + ToolTipPosition, + TransparentToolButton, +) + +from one_dragon.utils.i18_utils import gt +from one_dragon_qt.services.download_queue_service import ( + DownloadQueueService, + ResourceDownloadTask, + ResourceDownloadTaskState, +) +from one_dragon_qt.widgets.fast_scroll_area import FastScrollArea + + +class ElidedCaptionLabel(CaptionLabel): + """宽度不足时在右侧显示省略号的说明文字。""" + + def __init__( + self, + text: str, + parent: QWidget | None = None, + ) -> None: + self._full_text: str = '' + CaptionLabel.__init__(self, parent) + self.setText(text) + + def setText(self, text: str) -> None: + """保存完整文字并刷新省略显示。""" + self._full_text = text + self.setToolTip(text) + self._update_elided_text() + + def resizeEvent(self, event: QResizeEvent) -> None: + """尺寸变化后重新计算可显示文字。""" + super().resizeEvent(event) + self._update_elided_text() + + def _update_elided_text(self) -> None: + """按当前宽度生成省略文字。""" + elided_text = self.fontMetrics().elidedText( + self._full_text, + Qt.TextElideMode.ElideRight, + max(0, self.width()), + ) + CaptionLabel.setText(self, elided_text) + +class DownloadQueueItemWidget(QWidget): + """下载队列中的轻量任务行。""" + + def __init__( + self, + service: DownloadQueueService, + task: ResourceDownloadTask, + parent: QWidget | None = None, + ) -> None: + """创建任务行。""" + QWidget.__init__(self, parent) + self.service: DownloadQueueService = service + self.task: ResourceDownloadTask = task + self.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Preferred, + ) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 8, 0, 0) + layout.setSpacing(0) + + content_row = QHBoxLayout() + content_row.setContentsMargins(0, 0, 0, 0) + content_row.setSpacing(12) + + self.name_widget = QWidget(self) + self.name_widget.setFixedWidth(292) + name_layout = QVBoxLayout(self.name_widget) + name_layout.setContentsMargins(0, 0, 0, 0) + name_layout.setSpacing(3) + self.title_label = StrongBodyLabel(task.spec.title) + self.title_label.setToolTip(task.spec.title) + self.version_label = ElidedCaptionLabel(self._version_text()) + self.version_label.setSizePolicy( + QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Preferred, + ) + name_layout.addWidget(self.title_label) + name_layout.addWidget(self.version_label) + + self.status_widget = QWidget(self) + status_layout = QVBoxLayout(self.status_widget) + status_layout.setContentsMargins(0, 0, 0, 0) + status_layout.setSpacing(3) + self.status_label = ElidedCaptionLabel('') + self.status_label.setSizePolicy( + QSizePolicy.Policy.Ignored, + QSizePolicy.Policy.Preferred, + ) + status_layout.addWidget(self.status_label) + + self.progress_bar = ProgressBar() + self.progress_bar.setRange(0, 100) + self.progress_bar.setFixedHeight(5) + self.indeterminate_bar = IndeterminateProgressBar() + self.indeterminate_bar.setFixedHeight(5) + self.indeterminate_bar.setVisible(False) + status_layout.addWidget(self.progress_bar) + status_layout.addWidget(self.indeterminate_bar) + + self.action_button = TransparentToolButton(FluentIcon.CLOSE, self) + self.action_button.setFixedSize(28, 28) + self.action_button.installEventFilter( + ToolTipFilter( + self.action_button, + showDelay=500, + position=ToolTipPosition.TOP, + ) + ) + self.action_button.clicked.connect(self._on_action_clicked) + content_row.addWidget(self.name_widget) + content_row.addWidget(self.status_widget, stretch=1) + content_row.addWidget( + self.action_button, + alignment=Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, + ) + layout.addLayout(content_row) + + self.separator_spacer = QWidget(self) + self.separator_spacer.setFixedHeight(8) + layout.addWidget(self.separator_spacer) + self.separator = HorizontalSeparator(self) + layout.addWidget(self.separator) + self.refresh(task) + + def set_separator_visible(self, visible: bool) -> None: + """显示或隐藏任务行底部的分隔线及其前置留白。""" + self.separator_spacer.setVisible(visible) + self.separator.setVisible(visible) + + def _version_text(self) -> str: + """返回任务目标版本。""" + return self.task.spec.target_version or gt('最新版本') + + def refresh(self, task: ResourceDownloadTask) -> None: + """刷新任务状态。""" + self.task = task + active = task.state in ( + ResourceDownloadTaskState.DOWNLOADING, + ResourceDownloadTaskState.EXTRACTING, + ResourceDownloadTaskState.APPLYING, + ResourceDownloadTaskState.CANCELLING, + ) + indeterminate = task.state in ( + ResourceDownloadTaskState.EXTRACTING, + ResourceDownloadTaskState.APPLYING, + ) or ( + task.state == ResourceDownloadTaskState.DOWNLOADING + and task.progress.total_bytes == 0 + ) + self.indeterminate_bar.setVisible(active and indeterminate) + self.progress_bar.setVisible(active and not indeterminate) + self.progress_bar.setValue(int(task.progress.progress * 100)) + self.version_label.setText(self._version_text()) + self.status_label.setText(self._status_text()) + + action_icon, action_tooltip = { + ResourceDownloadTaskState.WAITING: (FluentIcon.CLOSE, '取消'), + ResourceDownloadTaskState.DOWNLOADING: (FluentIcon.CLOSE, '取消'), + ResourceDownloadTaskState.EXTRACTING: (FluentIcon.CLOSE, '取消'), + ResourceDownloadTaskState.APPLYING: (FluentIcon.CLOSE, '取消'), + ResourceDownloadTaskState.CANCELLING: (FluentIcon.CLOSE, '取消中'), + ResourceDownloadTaskState.CANCELLED: (FluentIcon.DELETE, '移除'), + ResourceDownloadTaskState.SUCCEEDED: (FluentIcon.DELETE, '移除'), + ResourceDownloadTaskState.FAILED: (FluentIcon.SYNC, '重试'), + }[task.state] + self.action_button.setIcon(action_icon) + self.action_button.setToolTip(gt(action_tooltip)) + self.action_button.setEnabled( + task.state != ResourceDownloadTaskState.CANCELLING + ) + + def _transfer_text(self) -> str: + """生成传输信息文本。""" + progress = self.task.progress + parts: list[str] = [] + if progress.source_id: + repo_config = getattr(self.service.ctx, 'repo_config', None) + source = ( + repo_config.get_resource_source(progress.source_id) + if repo_config is not None else None + ) + parts.append(source.label if source is not None else progress.source_id.upper()) + if progress.downloaded_bytes: + downloaded = format_byte_size(progress.downloaded_bytes) + if progress.total_bytes: + parts.append(f'{downloaded}/{format_byte_size(progress.total_bytes)}') + else: + parts.append(downloaded) + if progress.bytes_per_second: + parts.append(f'{format_byte_size(int(progress.bytes_per_second))}/s') + return ' · '.join(parts) + + def _status_text(self) -> str: + """生成中列的单一状态文本行。""" + if self.task.state == ResourceDownloadTaskState.DOWNLOADING: + transfer = self._transfer_text() + if self.task.progress.phase == 'connecting': + if transfer: + return f'{transfer} · {gt("正在连接")}' + return gt('正在连接') + if transfer: + return transfer + return gt('正在连接') + if self.task.state == ResourceDownloadTaskState.FAILED: + message = self.task.error_message or self.task.progress.message + if message: + return f'{gt("失败")} · {message}' + return gt('失败') + return gt({ + ResourceDownloadTaskState.WAITING: '等待中', + ResourceDownloadTaskState.EXTRACTING: '正在解压', + ResourceDownloadTaskState.APPLYING: '正在应用更新', + ResourceDownloadTaskState.CANCELLING: '正在取消', + ResourceDownloadTaskState.CANCELLED: '已取消', + ResourceDownloadTaskState.SUCCEEDED: '已完成', + }[self.task.state]) + + def _on_action_clicked(self) -> None: + """执行当前状态对应的操作。""" + if self.task.state in ( + ResourceDownloadTaskState.WAITING, + ResourceDownloadTaskState.DOWNLOADING, + ResourceDownloadTaskState.EXTRACTING, + ResourceDownloadTaskState.APPLYING, + ): + self.service.cancel(self.task.task_key) + elif self.task.state == ResourceDownloadTaskState.FAILED: + self.service.retry(self.task.task_key) + else: + self.service.remove(self.task.task_key) + + +class DownloadQueueDialog(MessageBoxBase): + """可关闭并重新打开的下载队列弹窗。""" + + def __init__( + self, + service: DownloadQueueService, + parent: QWidget, + ) -> None: + """创建下载队列弹窗。""" + super().__init__(parent) + self.service: DownloadQueueService = service + self.item_widgets: dict[str, DownloadQueueItemWidget] = {} + self._task_states: dict[str, ResourceDownloadTaskState] = {} + + self.buttonGroup.hide() + self.viewLayout.setSpacing(8) + + self.header_widget = QWidget(self) + header_layout = QHBoxLayout(self.header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.setSpacing(12) + + header_text_layout = QVBoxLayout() + header_text_layout.setContentsMargins(0, 0, 0, 0) + header_text_layout.setSpacing(8) + self.title_label = SubtitleLabel(gt('下载队列')) + header_text_layout.addWidget(self.title_label) + + self.summary_widget = QWidget(self.header_widget) + summary_layout = QHBoxLayout(self.summary_widget) + summary_layout.setContentsMargins(0, 0, 0, 0) + summary_layout.setSpacing(12) + self.running_summary_label = CaptionLabel('') + self.waiting_summary_label = CaptionLabel('') + self.completed_summary_label = CaptionLabel('') + self.failed_summary_label = CaptionLabel('') + summary_layout.addWidget(self.running_summary_label) + summary_layout.addWidget(self.waiting_summary_label) + summary_layout.addWidget(self.completed_summary_label) + summary_layout.addWidget(self.failed_summary_label) + summary_layout.addStretch(1) + header_text_layout.addWidget(self.summary_widget) + + self.close_button = TransparentToolButton(FluentIcon.CLOSE, self) + self.close_button.setFixedSize(32, 32) + self.close_button.setToolTip(gt('关闭')) + self.close_button.installEventFilter( + ToolTipFilter( + self.close_button, + showDelay=500, + position=ToolTipPosition.BOTTOM, + ) + ) + self.close_button.clicked.connect(self.reject) + header_layout.addLayout(header_text_layout, stretch=1) + header_layout.addWidget( + self.close_button, + alignment=Qt.AlignmentFlag.AlignTop, + ) + self.viewLayout.addWidget(self.header_widget) + + self.scroll_area = FastScrollArea() + self.scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.scroll_area.setStyleSheet( + 'QScrollArea { background-color: transparent; border: none; }' + ) + self.scroll_area.viewport().setStyleSheet('background-color: transparent;') + self.scroll_content = QWidget() + self.scroll_content.setStyleSheet('background-color: transparent;') + self.items_layout = QVBoxLayout(self.scroll_content) + self.items_layout.setContentsMargins(0, 0, 0, 0) + self.items_layout.setSpacing(0) + self.empty_label = BodyLabel(gt('暂无下载任务')) + self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.empty_label.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding, + ) + self.items_layout.addWidget(self.empty_label, stretch=1) + self.items_layout.addStretch(0) + self.scroll_area.setWidget(self.scroll_content) + self.scroll_area.setSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Expanding, + ) + self.viewLayout.addWidget(self.scroll_area, stretch=1) + + self.widget.setFixedSize(760, 480) + self.service.task_added.connect(self._on_task_added) + self.service.task_updated.connect(self._on_task_updated) + self.service.task_removed.connect(self._on_task_removed) + self.service.queue_updated.connect(self.refresh_summary) + self.refresh_all() + + def show_queue(self) -> None: + """显示并置顶下载队列。""" + self.refresh_all() + self.show() + self.raise_() + self.activateWindow() + QTimer.singleShot(0, self._scroll_to_running_task) + + def resizeEvent(self, e: QResizeEvent) -> None: + """父窗口尺寸变化后保持活动任务可见。""" + super().resizeEvent(e) + if hasattr(self, 'scroll_area'): + QTimer.singleShot(0, self._scroll_to_running_task) + + def refresh_all(self) -> None: + """根据服务状态重建缺失的任务行。""" + for task in self.service.tasks: + self._on_task_added(task) + self._on_task_updated(task) + self.refresh_summary() + + def refresh_summary(self) -> None: + """刷新队列状态摘要和空状态。""" + running = sum( + task.state in ( + ResourceDownloadTaskState.DOWNLOADING, + ResourceDownloadTaskState.EXTRACTING, + ResourceDownloadTaskState.APPLYING, + ResourceDownloadTaskState.CANCELLING, + ) + for task in self.service.tasks + ) + waiting = sum( + task.state == ResourceDownloadTaskState.WAITING + for task in self.service.tasks + ) + completed = sum( + task.state == ResourceDownloadTaskState.SUCCEEDED + for task in self.service.tasks + ) + failed = self.service.failed_count() + self.running_summary_label.setText(f'{gt("下载中")} {running}') + self.running_summary_label.setVisible(running > 0) + self.waiting_summary_label.setText(f'{gt("等待中")} {waiting}') + self.waiting_summary_label.setVisible(waiting > 0) + self.completed_summary_label.setText(f'{gt("已完成")} {completed}') + self.completed_summary_label.setVisible(completed > 0) + self.failed_summary_label.setText(f'{gt("失败")} {failed}') + self.failed_summary_label.setVisible(failed > 0) + self.summary_widget.setVisible( + running > 0 or waiting > 0 or completed > 0 or failed > 0 + ) + self.empty_label.setVisible(len(self.service.tasks) == 0) + + def _on_task_added(self, task: ResourceDownloadTask) -> None: + """为新任务增加状态行。""" + if task.task_key in self.item_widgets: + return + widget = DownloadQueueItemWidget(self.service, task, self.scroll_content) + self.items_layout.insertWidget(self.items_layout.count() - 1, widget) + self.item_widgets[task.task_key] = widget + self._task_states[task.task_key] = task.state + self._refresh_item_separators() + self.refresh_summary() + + def _refresh_item_separators(self) -> None: + """仅显示相邻任务行之间的分隔线。""" + item_widgets = list(self.item_widgets.values()) + for index, widget in enumerate(item_widgets): + widget.set_separator_visible(index < len(item_widgets) - 1) + + def _on_task_updated(self, task: ResourceDownloadTask) -> None: + """刷新已有任务行,并在后续任务开始下载时滚动到该卡片。""" + widget = self.item_widgets.get(task.task_key) + if widget is None: + self._on_task_added(task) + widget = self.item_widgets[task.task_key] + previous_state = self._task_states.get(task.task_key) + widget.refresh(task) + self._task_states[task.task_key] = task.state + if ( + self.isVisible() + and task.state == ResourceDownloadTaskState.DOWNLOADING + and previous_state != ResourceDownloadTaskState.DOWNLOADING + ): + QTimer.singleShot( + 0, + lambda task_key=task.task_key: self._scroll_to_task(task_key), + ) + self.refresh_summary() + + def _scroll_to_running_task(self) -> None: + """打开队列时将当前运行任务滚动到可见区域。""" + for task in self.service.tasks: + if task.state in ( + ResourceDownloadTaskState.DOWNLOADING, + ResourceDownloadTaskState.EXTRACTING, + ResourceDownloadTaskState.APPLYING, + ResourceDownloadTaskState.CANCELLING, + ): + self._scroll_to_task(task.task_key) + return + + def _scroll_to_task(self, task_key: str) -> None: + """将指定任务卡片滚动到可见区域。""" + widget = self.item_widgets.get(task_key) + if widget is not None: + self.scroll_area.ensureWidgetVisible(widget, 0, 8) + + def _on_task_removed(self, task_key: str) -> None: + """移除任务行。""" + widget = self.item_widgets.pop(task_key, None) + self._task_states.pop(task_key, None) + if widget is not None: + self.items_layout.removeWidget(widget) + widget.deleteLater() + self._refresh_item_separators() + self.refresh_summary() + +def format_byte_size(size: int) -> str: + """将字节数格式化为适合界面展示的文本。""" + value = float(size) + for unit in ('B', 'KB', 'MB', 'GB'): + if value < 1024 or unit == 'GB': + return f'{value:.1f} {unit}' + value /= 1024 + return f'{value:.1f} GB' diff --git a/src/one_dragon_qt/widgets/fast_scroll_area.py b/src/one_dragon_qt/widgets/fast_scroll_area.py new file mode 100644 index 0000000..a155ec1 --- /dev/null +++ b/src/one_dragon_qt/widgets/fast_scroll_area.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import QFrame, QWidget +from qfluentwidgets import ScrollArea as FluentScrollArea +from qfluentwidgets.common.smooth_scroll import SmoothMode + + +class FastScrollArea(FluentScrollArea): + """项目默认滚动区域。 + + 统一封装项目页面级滚动区域,便于集中切换和验证不同滚动实现。 + 当前使用实测最流畅的 qfluentwidgets.ScrollArea,并关闭平滑移动。 + """ + + def __init__( + self, + orient: Qt.Orientation | None = Qt.Orientation.Vertical, + parent: QWidget | None = None, + ) -> None: + FluentScrollArea.__init__(self, parent=parent) + self.setWidgetResizable(True) + self.setFrameShape(QFrame.Shape.NoFrame) + self.setStyleSheet("QScrollArea { background-color: transparent; border: none; }") + self.set_scroll_orientation(orient) + self.setSmoothMode(SmoothMode.NO_SMOOTH, Qt.Orientation.Vertical) + self.setSmoothMode(SmoothMode.NO_SMOOTH, Qt.Orientation.Horizontal) + + def set_scroll_orientation(self, orient: Qt.Orientation | None) -> None: + """设置主要滚动方向,None 表示双向滚动。""" + if orient == Qt.Orientation.Vertical: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + elif orient == Qt.Orientation.Horizontal: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + else: + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) + self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) diff --git a/src/script_chainer/context/script_chainer_context.py b/src/script_chainer/context/script_chainer_context.py index 7adf7af..f0446f7 100644 --- a/src/script_chainer/context/script_chainer_context.py +++ b/src/script_chainer/context/script_chainer_context.py @@ -1,6 +1,7 @@ import os import threading from concurrent.futures import ThreadPoolExecutor +from typing import TYPE_CHECKING from one_dragon.base.config.notify_config import NotifyConfig from one_dragon.base.push.push_service import PushService @@ -14,6 +15,9 @@ from script_chainer.config.script_config import ScriptChainConfig from script_chainer.services.github_update_service import GithubUpdateService +if TYPE_CHECKING: + from one_dragon_qt.services.download_queue_service import DownloadQueueService + ONE_DRAGON_CONTEXT_EXECUTOR = ThreadPoolExecutor(thread_name_prefix='one_dragon_context', max_workers=1) @@ -29,6 +33,17 @@ def __init__(self): self.notify_config: NotifyConfig = NotifyConfig(None, {}) self.run_config: RunConfig = RunConfig() self._init_lock = threading.Lock() + self._download_queue: DownloadQueueService | None = None + + @property + def download_queue(self) -> 'DownloadQueueService': + """进程内下载队列 仅配置器界面使用 延迟创建以免运行器加载 Qt。""" + if self._download_queue is None: + from one_dragon_qt.services.download_queue_service import ( + DownloadQueueService, + ) + self._download_queue = DownloadQueueService(self) + return self._download_queue def init(self) -> None: if not self._init_lock.acquire(blocking=False): @@ -133,5 +148,10 @@ def after_app_shutdown(self) -> None: App关闭后进行的操作 关闭一切可能资源操作 @return: """ + if self._download_queue is not None: + self._download_queue.cancel_all() + worker = self._download_queue._worker + if worker is not None: + worker.wait(5000) self.push_service.after_app_shutdown() ONE_DRAGON_CONTEXT_EXECUTOR.shutdown(wait=False, cancel_futures=True) diff --git a/src/script_chainer/gui/page/editor_setting_interface.py b/src/script_chainer/gui/page/editor_setting_interface.py index e8b382b..a8f14da 100644 --- a/src/script_chainer/gui/page/editor_setting_interface.py +++ b/src/script_chainer/gui/page/editor_setting_interface.py @@ -18,8 +18,13 @@ from one_dragon.utils import os_utils from one_dragon.utils.i18_utils import gt from one_dragon.utils.log_utils import log +from one_dragon_qt.services.download_queue_service import ( + ResourceDownloadTask, + ResourceDownloadTaskState, +) from one_dragon_qt.services.theme_manager import ThemeManager from one_dragon_qt.widgets.column import Column +from one_dragon_qt.widgets.download_queue_dialog import DownloadQueueDialog from one_dragon_qt.widgets.setting_card.combo_box_setting_card import ( ComboBoxSettingCard, ) @@ -32,33 +37,6 @@ from one_dragon_qt.widgets.setting_card.text_setting_card import TextSettingCard from one_dragon_qt.widgets.vertical_scroll_interface import VerticalScrollInterface from script_chainer.context.script_chainer_context import ScriptChainerContext -from script_chainer.services.github_update_service import GithubUpdateService - - -class GithubUpdateRunner(QThread): - - progress_changed = Signal(float, str) - update_finished = Signal(bool, str) - - def __init__(self, service: GithubUpdateService, target_version: str): - QThread.__init__(self) - self.service = service - self.target_version = target_version - self.progress_signal: dict[str, str | None] = {'signal': None} - - def run(self) -> None: - success, message = self.service.download_and_restart( - self.target_version, - self._on_progress, - self.progress_signal, - ) - self.update_finished.emit(success, message) - - def _on_progress(self, progress: float, message: str) -> None: - self.progress_changed.emit(progress, message) - - def cancel(self) -> None: - self.progress_signal['signal'] = 'cancel' class GithubUpdateChecker(QThread): @@ -119,14 +97,19 @@ def __init__(self, ctx: ScriptChainerContext, parent=None): self.cancel_btn.clicked.connect(self._on_cancel_clicked) self.cancel_btn.setVisible(False) + self.queue_btn = PushButton(gt('下载队列')) + self.queue_btn.clicked.connect(self._on_queue_clicked) + self.version_checker = GithubUpdateChecker(ctx) self.version_checker.check_finished.connect(self._on_version_check_finished) - self.update_runner: GithubUpdateRunner | None = None + self.update_task: ResourceDownloadTask | None = None + self.queue_dialog: DownloadQueueDialog | None = None + ctx.download_queue.task_updated.connect(self._on_queue_task_updated) MultiPushSettingCard.__init__( self, - btn_list=[self.channel_combo, self.check_btn, self.update_btn, self.cancel_btn], + btn_list=[self.channel_combo, self.check_btn, self.update_btn, self.cancel_btn, self.queue_btn], icon=FluentIcon.SYNC, title='程序更新', content='检查中...', @@ -217,28 +200,43 @@ def _on_update_clicked(self) -> None: self.update_btn.setText(gt('更新中')) self.cancel_btn.setVisible(True) self.cancel_btn.setEnabled(True) - self.setContent(gt('正在准备 GitHub 更新...')) - self.update_runner = GithubUpdateRunner(self.ctx.github_update_service, self.target_version) - self.update_runner.progress_changed.connect(self._on_update_progress) - self.update_runner.update_finished.connect(self._on_update_finished) - self.update_runner.start() + self.setContent(gt('已加入下载队列')) + spec = self.ctx.github_update_service.build_download_spec(self.target_version) + self.update_task = self.ctx.download_queue.enqueue(spec) def _on_cancel_clicked(self) -> None: - if not self._is_update_running() or self.update_runner is None: + if not self._is_update_running() or self.update_task is None: return - self.update_runner.cancel() + self.ctx.download_queue.cancel(self.update_task.task_key) self.cancel_btn.setDisabled(True) self.update_btn.setText(gt('取消中')) self.setContent(gt('正在取消下载...')) - def _on_update_progress(self, progress: float, message: str) -> None: - if progress > 0: - self.setContent(f'{message} {progress:.0%}') + def _on_queue_clicked(self) -> None: + if self.queue_dialog is None: + self.queue_dialog = DownloadQueueDialog(self.ctx.download_queue, self.window()) + self.queue_dialog.show_queue() + + def _on_queue_task_updated(self, task: ResourceDownloadTask) -> None: + if task is not self.update_task: + return + + if task.state == ResourceDownloadTaskState.SUCCEEDED: + self._on_update_finished(True, gt('更新包已下载完成,程序将重启并替换文件')) + elif task.state == ResourceDownloadTaskState.CANCELLED: + self._on_update_finished(False, gt('下载已取消')) + elif task.state == ResourceDownloadTaskState.FAILED: + self._on_update_finished(False, task.progress.message or gt('下载 GitHub 更新失败')) else: - self.setContent(message) + progress = task.progress + self.setContent( + f'{gt(progress.message)} {progress.progress:.0%}' if progress.progress > 0 + else gt(progress.message or '已加入下载队列') + ) def _on_update_finished(self, success: bool, message: str) -> None: + self.update_task = None self.cancel_btn.setVisible(False) self.cancel_btn.setEnabled(False) self.setContent(message) @@ -253,7 +251,7 @@ def _on_update_finished(self, success: bool, message: str) -> None: self.update_btn.setEnabled(True) def _is_update_running(self) -> bool: - return self.update_runner is not None and self.update_runner.isRunning() + return self.update_task is not None def _current_version(self) -> str: from one_dragon.version import __version__ diff --git a/src/script_chainer/services/github_update_service.py b/src/script_chainer/services/github_update_service.py index 37be06d..12f7063 100644 --- a/src/script_chainer/services/github_update_service.py +++ b/src/script_chainer/services/github_update_service.py @@ -6,11 +6,11 @@ import urllib.parse import urllib.request import zipfile -from collections.abc import Callable from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING from one_dragon.base.web.common_downloader import ( + GITHUB_SOURCE_ID, CommonDownloader, CommonDownloaderParam, ) @@ -19,11 +19,9 @@ from one_dragon.utils.log_utils import log if TYPE_CHECKING: + from one_dragon_qt.services.download_queue_service import ResourceDownloadSpec from script_chainer.context.script_chainer_context import ScriptChainerContext -ProgressCallback = Callable[[float, str], None] -ProgressSignal = dict[str, str | None] - APP_EXE_NAMES = ( 'OneDragon ScriptChainer.exe', 'OneDragon ScriptChainer Runner.exe', @@ -32,7 +30,6 @@ UPDATE_DIR_NAME = 'github_update' RUNTIME_DIR_NAME = '.runtime' GITHUB_USER_AGENT = 'OneDragon-ScriptChainer' -CANCEL_SIGNAL = 'cancel' class GithubUpdateService: @@ -41,62 +38,54 @@ class GithubUpdateService: def __init__(self, ctx: 'ScriptChainerContext'): self.ctx = ctx - def download_and_restart( - self, - target_tag: str | None = None, - progress_callback: ProgressCallback | None = None, - progress_signal: ProgressSignal | None = None, - ) -> tuple[bool, str]: - if not os_utils.run_in_exe(): - return False, gt('当前不是发布版,无法自动更新') + def build_download_spec(self, target_tag: str) -> 'ResourceDownloadSpec': + """构造可加入下载队列的更新任务。 + + 下载完成后在 after_download 中解压并启动替换脚本,脚本会等当前进程退出后替换文件, + 调用方在任务成功后需要退出程序。 + + Args: + target_tag: 要更新到的版本 tag。 + + Returns: + 下载队列任务规格。 + """ + from one_dragon.version import __version__ + from one_dragon_qt.services.download_queue_service import ResourceDownloadSpec work_dir = Path(os_utils.get_work_dir()) update_dir = work_dir / '.temp' / UPDATE_DIR_NAME stage_dir = update_dir / 'stage' + downloader_param = self._get_downloader_param(target_tag, update_dir) - try: + def before_download() -> None: + if not os_utils.run_in_exe(): + raise RuntimeError(gt('当前不是发布版,无法自动更新')) if update_dir.exists(): shutil.rmtree(update_dir) stage_dir.mkdir(parents=True, exist_ok=True) - if progress_callback is not None: - progress_callback(0, gt('正在获取 GitHub 更新版本')) - - latest_tag = target_tag or self.get_latest_tag() - downloader_param = self._get_downloader_param(latest_tag, update_dir) - zip_path = update_dir / downloader_param.save_file_name - downloader = CommonDownloader(downloader_param) - proxy = self.ctx.env_config.personal_proxy if self.ctx.env_config.is_personal_proxy else None - ghproxy_url = self.ctx.env_config.gh_proxy_url if self.ctx.env_config.is_gh_proxy else None - - if not downloader.download( - proxy_url=proxy, - ghproxy_url=ghproxy_url, - skip_if_existed=False, - progress_signal=progress_signal, - progress_callback=progress_callback, - ): - if self._is_cancelled(progress_signal): - return False, gt('下载已取消') - return False, gt('下载 GitHub 更新失败') - - if self._is_cancelled(progress_signal): - return False, gt('下载已取消') - - if progress_callback is not None: - progress_callback(1, gt('正在解压更新包')) - self._extract_release(zip_path, stage_dir) - + def after_download(success: bool) -> None: + if not success: + return + self._extract_release(update_dir / downloader_param.save_file_name, stage_dir) update_items = self._get_update_items(stage_dir) if not update_items: - return False, gt('更新包中没有可替换文件') - + raise RuntimeError(gt('更新包中没有可替换文件')) script_path = self._write_apply_script(work_dir, update_dir, update_items) self._start_apply_script(script_path, work_dir) - return True, gt('更新包已下载完成,程序将重启并替换文件') - except Exception as e: - log.error('准备 GitHub 更新失败', exc_info=True) - return False, f"{gt('准备 GitHub 更新失败')}: {e}" + + return ResourceDownloadSpec( + resource_id='script_chainer', + resource_type='app', + title=gt('千机链程序更新'), + current_version=__version__, + target_version=target_tag, + downloader_factory=lambda: CommonDownloader(downloader_param), + note=gt('下载完成后程序将重启并替换文件'), + before_download=before_download, + after_download=after_download, + ) def get_latest_tags(self) -> tuple[str, str]: stable_tag = '' @@ -190,7 +179,7 @@ def _get_downloader_param(self, latest_tag: str, update_dir: Path) -> CommonDown return CommonDownloaderParam( save_file_path=str(update_dir), save_file_name=release_zip_name, - github_release_download_url=download_url, + download_urls={GITHUB_SOURCE_ID: download_url}, ) def _extract_release(self, zip_path: Path, stage_dir: Path) -> None: @@ -331,12 +320,6 @@ def _start_apply_script(self, script_path: Path, work_dir: Path) -> None: creationflags=subprocess.CREATE_NO_WINDOW, ) - def _is_cancelled(self, progress_signal: ProgressSignal | None) -> bool: - return ( - progress_signal is not None - and progress_signal.get('signal') == CANCEL_SIGNAL - ) - def _ps_quote(self, value: str) -> str: return "'" + value.replace("'", "''") + "'" From ef3960f755ed06328823fe1f7cca15135322a4c2 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:12:42 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20CNB=20?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E4=B8=8B=E8=BD=BD=E6=BA=90=EF=BC=8C=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=20ZZZ=20RepoConfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 搬入 RepoConfig 与 config/repository.yml(github/cnb 两个源,中文推荐 cnb) - EnvConfig 增加 resource_source 等下载源选择与自动回退;更新包按候选源顺序下载 - 设置页增加「下载源」选项;repository.yml 打进 exe - 同步 .cnb.yml、release 同步脚本、sync-cnb 工作流与发布后触发 CNB 同步步骤 --- .cnb.yml | 20 + .cnb/web_trigger.yml | 5 + .github/workflows/build-release.yml | 37 ++ .github/workflows/sync-cnb.yml | 23 + config/repository.yml | 47 ++ deploy/OneDragon ScriptChainer.spec | 1 + src/one_dragon/envs/env_config.py | 57 +- src/one_dragon/envs/repo_config.py | 613 ++++++++++++++++++ .../widgets/download_queue_dialog.py | 6 +- .../context/script_chainer_context.py | 4 +- .../gui/page/editor_setting_interface.py | 28 +- .../gui/page/script_setting_cards.py | 5 +- .../services/github_update_service.py | 8 +- src/script_chainer/utils/runner_utils.py | 1 + src/script_chainer/win_exe/script_runner.py | 2 +- tools/ci/sync_github_release_to_cnb.py | 323 +++++++++ 16 files changed, 1156 insertions(+), 24 deletions(-) create mode 100644 .cnb.yml create mode 100644 .cnb/web_trigger.yml create mode 100644 .github/workflows/sync-cnb.yml create mode 100644 config/repository.yml create mode 100644 src/one_dragon/envs/repo_config.py create mode 100644 tools/ci/sync_github_release_to_cnb.py diff --git a/.cnb.yml b/.cnb.yml new file mode 100644 index 0000000..839e551 --- /dev/null +++ b/.cnb.yml @@ -0,0 +1,20 @@ +main: + web_trigger_release: + - docker: + image: python:3.11 + stages: + - name: 同步最新 GitHub Release + timeout: 21600000 + script: | + python -m pip install --disable-pip-version-check requests==2.32.3 + python tools/ci/sync_github_release_to_cnb.py + api_trigger_release: + - docker: + image: python:3.11 + stages: + - name: 同步指定 GitHub Release + timeout: 21600000 + script: | + test -n "$RELEASE_TAG" + python -m pip install --disable-pip-version-check requests==2.32.3 + python tools/ci/sync_github_release_to_cnb.py --release-tag "$RELEASE_TAG" diff --git a/.cnb/web_trigger.yml b/.cnb/web_trigger.yml new file mode 100644 index 0000000..c5116c8 --- /dev/null +++ b/.cnb/web_trigger.yml @@ -0,0 +1,5 @@ +branch: + - buttons: + - name: 触发上传 + description: 上传Release + event: web_trigger_release diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index be0387c..53d41b1 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -353,3 +353,40 @@ jobs: gh workflow run --repo $GITHUB_REPOSITORY mirrorchyan-release_note.yml -f tag=${{ needs.build.outputs.tag }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Trigger CNB Release sync + if: ${{ github.repository == 'OneDragon-Anything/OneDragon-ScriptChainer' }} + continue-on-error: true + shell: pwsh + env: + CNB_TOKEN: ${{ secrets.CNB_TOKEN }} + RELEASE_TAG: ${{ needs.build.outputs.version }} + run: | + if ([string]::IsNullOrWhiteSpace($env:CNB_TOKEN)) { + throw 'Missing GitHub Actions secret: CNB_TOKEN' + } + + $headers = @{ + Accept = 'application/vnd.cnb.api+json' + Authorization = "Bearer $env:CNB_TOKEN" + } + $body = @{ + branch = 'main' + event = 'api_trigger_release' + title = "Sync GitHub Release $env:RELEASE_TAG" + env = @{ + RELEASE_TAG = $env:RELEASE_TAG + } + } | ConvertTo-Json -Depth 3 + + $response = Invoke-RestMethod ` + -Uri 'https://api.cnb.cool/OneDragon-Anything/OneDragon-ScriptChainer/-/build/start' ` + -Method Post ` + -Headers $headers ` + -ContentType 'application/json' ` + -TimeoutSec 60 ` + -Body $body + $response | ConvertTo-Json -Depth 5 + if ($response.success -ne $true) { + throw "CNB build trigger failed: $($response.message)" + } diff --git a/.github/workflows/sync-cnb.yml b/.github/workflows/sync-cnb.yml new file mode 100644 index 0000000..2ca8139 --- /dev/null +++ b/.github/workflows/sync-cnb.yml @@ -0,0 +1,23 @@ +name: Sync to CNB + +on: + push: + workflow_dispatch: + +permissions: + contents: read + +jobs: + sync: + if: ${{ github.repository == 'OneDragon-Anything/OneDragon-ScriptChainer' }} + runs-on: ubuntu-latest + steps: + - name: Push to CNB + env: + CNB_TOKEN: ${{ secrets.CNB_GIT_TOKEN }} + run: | + git clone --bare "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repository.git + git -C repository.git remote add cnb "https://cnb:${CNB_TOKEN}@cnb.cool/OneDragon-Anything/OneDragon-ScriptChainer.git" + git -C repository.git push --force --prune cnb \ + '+refs/heads/*:refs/heads/*' \ + '+refs/tags/*:refs/tags/*' diff --git a/config/repository.yml b/config/repository.yml new file mode 100644 index 0000000..62b2f98 --- /dev/null +++ b/config/repository.yml @@ -0,0 +1,47 @@ +repositories: + primary: github + primary_branch: main + branches: + main: + label: 主分支 + desc: "" + options: + github: + label: GitHub + url: "https://github.com/OneDragon-Anything/OneDragon-ScriptChainer.git" + use_proxy: true + cnb: + label: CNB + url: "https://cnb.cool/OneDragon-Anything/OneDragon-ScriptChainer.git" + use_proxy: false +resource_download: + default: github + options: + github: + label: GitHub + value: "https://example.com/resource" + sources: + github: + label: GitHub + use_proxy: true + cnb: + label: CNB + use_proxy: false + recommend: + zh: cnb + en: github + repos: + main: + github: "https://github.com/OneDragon-Anything/OneDragon-ScriptChainer/releases" + cnb: "https://cnb.cool/OneDragon-Anything/OneDragon-ScriptChainer/-/releases" +regions: + china_cnb: + label: 中国 - CNB + repository: cnb + values: + resource_source: cnb + oversea: + label: 海外 + repository: github + values: + resource_source: github diff --git a/deploy/OneDragon ScriptChainer.spec b/deploy/OneDragon ScriptChainer.spec index 03beccb..6ebf821 100644 --- a/deploy/OneDragon ScriptChainer.spec +++ b/deploy/OneDragon ScriptChainer.spec @@ -7,6 +7,7 @@ a = Analysis( binaries=[], datas=[ ('../config/project.yml', 'resources/config'), + ('../config/repository.yml', 'resources/config'), ('../assets', 'resources/assets') ], hiddenimports=[], diff --git a/src/one_dragon/envs/env_config.py b/src/one_dragon/envs/env_config.py index efb1867..2651ede 100644 --- a/src/one_dragon/envs/env_config.py +++ b/src/one_dragon/envs/env_config.py @@ -4,7 +4,8 @@ from one_dragon.base.config.config_item import ConfigItem from one_dragon.base.config.yaml_config import YamlConfig -from one_dragon.utils import os_utils +from one_dragon.envs.repo_config import RepoConfig +from one_dragon.utils import i18_utils, os_utils GH_PROXY_URL = 'https://ghfast.top' # 免费代理的路径 @@ -69,8 +70,9 @@ class ScreenshotMethodEnum(Enum): class EnvConfig(YamlConfig): - def __init__(self): + def __init__(self, repo_config: RepoConfig) -> None: YamlConfig.__init__(self, module_name='env') + self.repo_config: RepoConfig = repo_config @property def uv_path(self) -> str: @@ -182,20 +184,55 @@ def force_update(self, new_value: bool) -> None: """ self.update('force_update', new_value) - def get_resource_source_order(self) -> list[str]: - """资源下载的候选源顺序 千机链只从 GitHub Release 下载。""" - return ['github'] + @property + def resource_source(self) -> str: + """资源release下载源选择 自动模式按 上次成功源>语言推荐>其余源 的顺序尝试。""" + value = self.get('resource_source', RepoConfig.AUTO_RESOURCE_SOURCE_VALUE) + return value if isinstance(value, str) and value else RepoConfig.AUTO_RESOURCE_SOURCE_VALUE + + @resource_source.setter + def resource_source(self, new_value: str) -> None: + """更新资源release下载源选择。""" + self.update('resource_source', new_value) @property - def is_resource_source_auto(self) -> bool: - """是否由下载器自动选择资源源 千机链只有一个源 无需自动回退。""" - return False + def last_resource_source(self) -> str: + """最近一次资源下载成功使用的源 ID。""" + return self.get('last_resource_source', '') + + @last_resource_source.setter + def last_resource_source(self, new_value: str) -> None: + """记录最近一次资源下载成功使用的源 ID。""" + self.update('last_resource_source', new_value) + + def get_resource_source_order(self) -> list[str]: + """获取资源下载的候选源顺序 用户指定>上次成功>语言推荐>其余。""" + return self.repo_config.get_resource_source_candidates( + user_choice=self.resource_source, + last_success=self.last_resource_source, + language=i18_utils.get_default_lang(), + ) def mark_resource_source_success(self, source_id: str) -> None: - """记录资源下载成功的源 千机链只有一个源 无需记录。""" + """记录本次资源下载成功使用的源。""" + self.last_resource_source = source_id + + @property + def is_resource_source_auto(self) -> bool: + """是否由下载器自动选择资源源。""" + return self.resource_source == RepoConfig.AUTO_RESOURCE_SOURCE_VALUE def mark_resource_source_failure(self, source_id: str) -> None: - """记录资源下载失败的源 千机链只有一个源 无需记录。""" + """自动模式下使已经失效的上次成功源不再保持首选。""" + if ( + self.is_resource_source_auto + and self.last_resource_source == source_id + ): + self.last_resource_source = '' + + def get_recommended_resource_source(self) -> str | None: + """获取按当前语言推荐的资源下载源 ID。""" + return self.repo_config.get_recommended_resource_source(i18_utils.get_default_lang()) @property def auto_update(self) -> bool: diff --git a/src/one_dragon/envs/repo_config.py b/src/one_dragon/envs/repo_config.py new file mode 100644 index 0000000..d8d790e --- /dev/null +++ b/src/one_dragon/envs/repo_config.py @@ -0,0 +1,613 @@ +from dataclasses import dataclass +from urllib.parse import urlparse + +from one_dragon.base.config.config_item import ConfigItem +from one_dragon.base.config.yaml_config import YamlConfig + + +@dataclass(frozen=True) +class RepositoryItem: + """YAML 中的一项代码源。""" + + repository_id: str + label: str + url: str + use_proxy: bool + + @property + def config_item(self) -> ConfigItem: + return ConfigItem(self.label, self.url) + + +@dataclass(frozen=True) +class RepositoryBranch: + """YAML 中的一项代码分支。""" + + branch_name: str + label: str + desc: str + + @property + def config_item(self) -> ConfigItem: + return ConfigItem(self.label, self.branch_name, self.desc) + + +@dataclass(frozen=True) +class RegionPreset: + """YAML 中的一项代码源地区预设。""" + + region_id: str + label: str + repository_id: str + values: dict[str, str] + + @property + def config_item(self) -> ConfigItem: + return ConfigItem(self.label, self.region_id) + + +@dataclass(frozen=True) +class SourceOption: + """YAML 中的一项下载源。""" + + source_id: str + label: str + value: str + + @property + def config_item(self) -> ConfigItem: + return ConfigItem(self.label, self.value) + + +@dataclass(frozen=True) +class ResourceSource: + """YAML 中的一项资源release下载源。""" + + source_id: str + label: str + use_proxy: bool + + @property + def config_item(self) -> ConfigItem: + return ConfigItem(self.label, self.source_id) + + +@dataclass(frozen=True) +class ModelResourceDefinition: + """YAML 中的一项项目识别模型。""" + + config_key: str + display_name: str + default_model: str + repo_key: str + release_tag: str + gpu_config_key: str | None + required_files: tuple[str, ...] + + +class RepoConfig(YamlConfig): + """项目代码仓库、下载源和地区预设配置。 + + 使用 OneDragon 框架的项目应在 ``config/repository.yml`` 中提供: + + - ``repositories``:包含 ``primary``、``primary_branch``、``branches`` 和 ``options`` 的代码仓库配置组; + - 顶层下载源配置组:每组包含 ``default`` 和 ``options``; + - ``regions``:地区预设映射,包含显示标题、代码源和环境配置值。 + + 最小 YAML 示例: + + .. code-block:: yaml + + repositories: + primary: main + primary_branch: main + branches: + main: + label: 主分支 + desc: 选择后请点击同步最新代码 + options: + main: + label: 主仓库 + url: https://example.com/example.git + use_proxy: false + env_source: + default: main + options: + main: + label: 默认 + value: https://example.com/env/releases/download + regions: + default: + label: 默认 + repository: main + values: + env_source: main # 可引用下载源 options 中的选项名 会解析为对应 value + """ + + AUTO_REPOSITORY_VALUE = 'auto' + AUTO_RESOURCE_SOURCE_VALUE = 'auto' + _SOURCE_EXCLUDED_KEYS = {'repositories', 'regions', 'resource_download'} + + def __init__(self) -> None: + YamlConfig.__init__( + self, + module_name='repository', + ) + repository_config = self._get_repository_config() + primary_branch = repository_config.get('primary_branch', '') + if not isinstance(primary_branch, str) or not primary_branch.strip(): + raise ValueError('repositories 必须配置 primary_branch') + self.primary_branch: str = primary_branch + self.branches: tuple[RepositoryBranch, ...] = self._load_branches(repository_config) + if not any(branch.branch_name == self.primary_branch for branch in self.branches): + raise ValueError( + f'repositories.primary_branch {self.primary_branch} 不在 repositories.branches 中' + ) + self.repositories: tuple[RepositoryItem, ...] = self._load_repositories(repository_config) + self._repositories_by_id: dict[str, RepositoryItem] = { + repository.repository_id: repository for repository in self.repositories + } + primary_repository_id = repository_config.get('primary', '') + if not isinstance(primary_repository_id, str) or not primary_repository_id: + raise ValueError('repositories 必须配置 primary') + self.primary_repository: RepositoryItem = self._get_repository( + primary_repository_id, + '主仓库', + ) + self.sources: dict[str, tuple[SourceOption, ...]] = self._load_sources() + self.source_defaults: dict[str, str] = self._load_source_defaults() + self.regions: tuple[RegionPreset, ...] = self._load_regions() + self._regions_by_id: dict[str, RegionPreset] = { + region.region_id: region for region in self.regions + } + self.resource_sources: tuple[ResourceSource, ...] = self._load_resource_sources() + self._resource_sources_by_id: dict[str, ResourceSource] = { + source.source_id: source for source in self.resource_sources + } + self.resource_recommend: dict[str, str] = self._load_resource_recommend() + self.resource_repos: dict[str, dict[str, str]] = self._load_resource_repos() + self.model_resources: tuple[ModelResourceDefinition, ...] = ( + self._load_model_resources() + ) + + def _get_repository_config(self) -> dict: + raw_repositories = self.get('repositories', {}) + if not isinstance(raw_repositories, dict): + raise ValueError('config/repository.yml 必须配置 repositories') + if not raw_repositories: + raise ValueError('repositories 必须配置 primary') + if 'primary' not in raw_repositories: + raise ValueError('repositories 必须配置 primary') + if 'primary_branch' not in raw_repositories: + raise ValueError('repositories 必须配置 primary_branch') + if 'branches' not in raw_repositories: + raise ValueError('repositories 必须配置 branches') + if 'options' not in raw_repositories: + raise ValueError('repositories 必须配置 options') + return raw_repositories + + def _load_branches(self, repository_config: dict) -> tuple[RepositoryBranch, ...]: + raw_branches = repository_config.get('branches', {}) + if not isinstance(raw_branches, dict) or not raw_branches: + raise ValueError('repositories.branches 必须配置代码分支') + + branches: list[RepositoryBranch] = [] + for branch_name, raw_branch in raw_branches.items(): + if not isinstance(branch_name, str) or not branch_name or not isinstance(raw_branch, dict): + raise ValueError('代码分支配置必须是分支名到对象的映射') + label = raw_branch.get('label', '') + desc = raw_branch.get('desc', '') + if not isinstance(label, str) or not label: + raise ValueError(f'代码分支 {branch_name} 必须配置 label') + if not isinstance(desc, str): + raise ValueError(f'代码分支 {branch_name} 的 desc 必须是字符串') + branches.append( + RepositoryBranch( + branch_name=branch_name, + label=label, + desc=desc, + ) + ) + return tuple(branches) + + def _load_repositories(self, repository_config: dict) -> tuple[RepositoryItem, ...]: + raw_repositories = repository_config.get('options', {}) + if not isinstance(raw_repositories, dict) or not raw_repositories: + raise ValueError('repositories.options 必须配置代码源') + + repositories: list[RepositoryItem] = [] + for repository_id, raw_repository in raw_repositories.items(): + if not isinstance(repository_id, str) or not isinstance(raw_repository, dict): + raise ValueError('代码源配置必须是 ID 到对象的映射') + label = raw_repository.get('label', '') + url = raw_repository.get('url', '') + use_proxy = raw_repository.get('use_proxy', False) + if not repository_id or not isinstance(label, str) or not label or not isinstance(url, str) or not url: + raise ValueError(f'代码源 {repository_id} 必须配置 label 和 url') + if not isinstance(use_proxy, bool): + raise ValueError(f'代码源 {repository_id} 的 use_proxy 必须是布尔值') + parsed_url = urlparse(url) + if parsed_url.scheme != 'https' or not parsed_url.netloc: + raise ValueError(f'代码源 {repository_id} 必须使用 HTTPS 链接') + repositories.append( + RepositoryItem( + repository_id=repository_id, + label=label, + url=url, + use_proxy=use_proxy, + ) + ) + return tuple(repositories) + + def _load_regions(self) -> tuple[RegionPreset, ...]: + raw_regions = self.get('regions', {}) + if not isinstance(raw_regions, dict) or not raw_regions: + raise ValueError('config/repository.yml 必须配置 regions') + + regions: list[RegionPreset] = [] + for region_id, raw_region in raw_regions.items(): + if not isinstance(region_id, str) or not isinstance(raw_region, dict): + raise ValueError('地区预设配置必须是 ID 到对象的映射') + label = raw_region.get('label', '') + repository_id = raw_region.get('repository', '') + values = raw_region.get('values', {}) + if not isinstance(label, str) or not label or not isinstance(repository_id, str) or not repository_id: + raise ValueError(f'地区预设 {region_id} 必须配置 label 和 repository') + if not isinstance(values, dict) or any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in values.items() + ): + raise ValueError(f'地区预设 {region_id} 的 values 必须是字符串映射') + self._get_repository(repository_id, f'地区 {region_id}') + regions.append( + RegionPreset( + region_id=region_id, + label=label, + repository_id=repository_id, + values={ + key: self._resolve_region_value(key, value) + for key, value in values.items() + }, + ) + ) + return tuple(regions) + + def _resolve_region_value(self, key: str, value: str) -> str: + """地区预设的下载源字段:完整 URL 原样保留,选项名解析为对应 URL。""" + if value.startswith('http://') or value.startswith('https://'): + # 旧格式:直接写完整 URL,保持向后兼容 + return value + options = self.sources.get(key) + if options is None: + # 非下载源字段(如 proxy_type)保持原样 + return value + for option in options: + if option.source_id == value: + return option.value + raise ValueError( + f'地区预设值 {key}={value} 不是下载源 {key} 的选项' + ) + + def _load_sources(self) -> dict[str, tuple[SourceOption, ...]]: + if 'sources' in self.data: + raise ValueError('config/repository.yml 不再支持顶层 sources,请将下载源配置放到顶层') + if not isinstance(self.data, dict): + return {} + + sources: dict[str, tuple[SourceOption, ...]] = {} + for source_name, raw_source_group in self.data.items(): + if source_name in self._SOURCE_EXCLUDED_KEYS: + continue + if not isinstance(source_name, str) or not isinstance(raw_source_group, dict): + raise ValueError('下载源配置必须是名称到对象的映射') + raw_options = raw_source_group.get('options', {}) + if not isinstance(raw_options, dict): + raise ValueError(f'下载源 {source_name} 的 options 必须是映射') + options: list[SourceOption] = [] + for source_id, raw_option in raw_options.items(): + if not isinstance(source_id, str) or not isinstance(raw_option, dict): + raise ValueError(f'下载源 {source_name} 的选项配置无效') + label = raw_option.get('label', '') + value = raw_option.get('value', '') + if not isinstance(label, str) or not label or not isinstance(value, str) or not value: + raise ValueError(f'下载源 {source_name} 的选项必须配置 label 和 value') + options.append(SourceOption(source_id, label, value)) + sources[source_name] = tuple(options) + return sources + + def _load_source_defaults(self) -> dict[str, str]: + defaults: dict[str, str] = {} + for source_name, raw_source_group in self.data.items(): + if source_name in self._SOURCE_EXCLUDED_KEYS or not isinstance(raw_source_group, dict): + continue + default_id = raw_source_group.get('default', '') + options = self.sources.get(source_name, ()) + default_option = next( + (option for option in options if option.source_id == default_id), + None, + ) + if not isinstance(default_id, str) or not default_id: + raise ValueError(f'下载源 {source_name} 必须配置 default') + if default_option is None: + raise ValueError(f'下载源 {source_name} 的默认值 {default_id} 不在 options 中') + defaults[source_name] = default_option.value + return defaults + + def _get_resource_download_config(self) -> dict: + raw = self.data.get('resource_download', {}) + if raw is None: + return {} + if not isinstance(raw, dict): + raise ValueError('resource_download 必须是映射') + return raw + + def _load_resource_sources(self) -> tuple[ResourceSource, ...]: + raw_sources = self._get_resource_download_config().get('sources', {}) + if not isinstance(raw_sources, dict): + raise ValueError('resource_download.sources 必须是映射') + + sources: list[ResourceSource] = [] + for source_id, raw_source in raw_sources.items(): + if not isinstance(source_id, str) or not source_id or not isinstance(raw_source, dict): + raise ValueError('resource_download.sources 必须是 ID 到对象的映射') + label = raw_source.get('label', '') + use_proxy = raw_source.get('use_proxy', False) + if not isinstance(label, str) or not label: + raise ValueError(f'资源下载源 {source_id} 必须配置 label') + if not isinstance(use_proxy, bool): + raise ValueError(f'资源下载源 {source_id} 的 use_proxy 必须是布尔值') + sources.append(ResourceSource(source_id=source_id, label=label, use_proxy=use_proxy)) + return tuple(sources) + + def _load_resource_recommend(self) -> dict[str, str]: + raw_recommend = self._get_resource_download_config().get('recommend', {}) + if not isinstance(raw_recommend, dict): + raise ValueError('resource_download.recommend 必须是映射') + + recommend: dict[str, str] = {} + for language, source_id in raw_recommend.items(): + if not isinstance(language, str) or not isinstance(source_id, str): + raise ValueError('resource_download.recommend 必须是语言到源 ID 的映射') + if source_id not in self._resource_sources_by_id: + raise ValueError(f'resource_download.recommend 的 {source_id} 不在 sources 中') + recommend[language] = source_id + return recommend + + def _load_resource_repos(self) -> dict[str, dict[str, str]]: + raw_repos = self._get_resource_download_config().get('repos', {}) + if not isinstance(raw_repos, dict): + raise ValueError('resource_download.repos 必须是映射') + + repos: dict[str, dict[str, str]] = {} + for repo_key, raw_urls in raw_repos.items(): + if not isinstance(repo_key, str) or not repo_key or not isinstance(raw_urls, dict): + raise ValueError('resource_download.repos 必须是仓库到源 URL 的映射') + urls: dict[str, str] = {} + for source_id, url in raw_urls.items(): + if source_id not in self._resource_sources_by_id: + raise ValueError(f'资源仓库 {repo_key} 的 {source_id} 不在 sources 中') + if not isinstance(url, str) or not url: + raise ValueError(f'资源仓库 {repo_key} 的 {source_id} 必须配置 URL') + parsed_url = urlparse(url) + if parsed_url.scheme != 'https' or not parsed_url.netloc: + raise ValueError(f'资源仓库 {repo_key} 的 {source_id} 必须使用 HTTPS 链接') + urls[source_id] = url + repos[repo_key] = urls + return repos + + def _load_model_resources(self) -> tuple[ModelResourceDefinition, ...]: + raw_models = self._get_resource_download_config().get('models', {}) + if not isinstance(raw_models, dict): + raise ValueError('resource_download.models 必须是映射') + + models: list[ModelResourceDefinition] = [] + for config_key, raw_model in raw_models.items(): + if ( + not isinstance(config_key, str) + or not config_key + or not isinstance(raw_model, dict) + ): + raise ValueError('resource_download.models 必须是配置键到对象的映射') + display_name = raw_model.get('label', '') + default_model = raw_model.get('default', '') + repo_key = raw_model.get('repo', '') + release_tag = raw_model.get('tag', '') + gpu_config_key = raw_model.get('gpu_config') + required_files = raw_model.get( + 'required_files', + ['model.onnx', 'labels.csv'], + ) + if not isinstance(display_name, str) or not display_name: + raise ValueError(f'模型资源 {config_key} 必须配置 label') + if not isinstance(default_model, str) or not default_model: + raise ValueError(f'模型资源 {config_key} 必须配置 default') + if ( + not isinstance(repo_key, str) + or not repo_key + or repo_key not in self.resource_repos + ): + raise ValueError( + f'模型资源 {config_key} 的 repo 必须存在于 resource_download.repos' + ) + if not isinstance(release_tag, str): + raise ValueError(f'模型资源 {config_key} 的 tag 必须是字符串') + if gpu_config_key is not None and not isinstance(gpu_config_key, str): + raise ValueError(f'模型资源 {config_key} 的 gpu_config 必须是字符串') + if ( + not isinstance(required_files, list) + or not required_files + or any( + not isinstance(file_name, str) or not file_name + for file_name in required_files + ) + ): + raise ValueError( + f'模型资源 {config_key} 的 required_files 必须是非空字符串列表' + ) + models.append( + ModelResourceDefinition( + config_key=config_key, + display_name=display_name, + default_model=default_model, + repo_key=repo_key, + release_tag=release_tag, + gpu_config_key=gpu_config_key, + required_files=tuple(required_files), + ) + ) + return tuple(models) + + def _get_repository(self, repository_id: str, field_name: str) -> RepositoryItem: + repository = self._repositories_by_id.get(repository_id) + if repository is None: + raise ValueError(f'{field_name} {repository_id} 不在 repositories.options 中') + return repository + + @property + def branch_options(self) -> list[ConfigItem]: + """获取供代码版本下拉框使用的分支选项。""" + return [branch.config_item for branch in self.branches] + + @property + def repository_options(self) -> list[ConfigItem]: + """获取供设置界面使用的自动和具体代码源选项。""" + return [ + ConfigItem('自动', self.AUTO_REPOSITORY_VALUE), + *(repository.config_item for repository in self.repositories), + ] + + @property + def region_options(self) -> list[ConfigItem]: + """获取供设置界面使用的地区预设选项。""" + return [region.config_item for region in self.regions] + + def find_repository(self, value: str) -> RepositoryItem | None: + """按仓库 ID、显示标题或 URL 查找代码源。""" + for repository in self.repositories: + if value in (repository.repository_id, repository.label, repository.url): + return repository + return None + + def get_region_preset(self, region_id: str) -> RegionPreset | None: + """按地区 ID 查找地区预设。""" + return self._regions_by_id.get(region_id) + + def get_source_options(self, source_name: str) -> list[ConfigItem]: + """获取指定下载源的设置选项。""" + return [option.config_item for option in self.sources.get(source_name, ())] + + def get_source_default(self, source_name: str) -> str: + """获取指定下载源的默认值。""" + return self.source_defaults.get(source_name, '') + + def get_source_values(self, source_name: str) -> tuple[SourceOption, ...]: + """获取指定下载源的测速选项。""" + return self.sources.get(source_name, ()) + + @property + def resource_source_options(self) -> list[ConfigItem]: + """获取供设置界面使用的自动和具体资源下载源选项。""" + return [ + ConfigItem('自动', self.AUTO_RESOURCE_SOURCE_VALUE), + *(source.config_item for source in self.resource_sources), + ] + + def get_resource_source(self, source_id: str) -> ResourceSource | None: + """按源 ID 查找资源下载源。""" + return self._resource_sources_by_id.get(source_id) + + def get_recommended_resource_source(self, language: str) -> str | None: + """按语言获取推荐的资源下载源 ID。""" + return self.resource_recommend.get(language) + + def get_model_resource( + self, + config_key: str, + ) -> ModelResourceDefinition | None: + """按配置键获取项目模型资源定义。""" + return next( + ( + resource + for resource in self.model_resources + if resource.config_key == config_key + ), + None, + ) + + def get_resource_download_base_url( + self, + repo_key: str, + tag: str, + source_id: str, + ) -> str: + """获取资源仓库指定源的 release 下载根地址。""" + release_root = self.resource_repos.get(repo_key, {}).get(source_id, '') + if not release_root: + return '' + if tag: + return f'{release_root}/download/{tag}' + return f'{release_root}/latest/download' + + def get_model_download_base_url( + self, + config_key: str, + source_id: str, + ) -> str: + """获取项目模型指定源的 release 下载根地址。""" + resource = self.get_model_resource(config_key) + if resource is None: + return '' + return self.get_resource_download_base_url( + resource.repo_key, + resource.release_tag, + source_id, + ) + + def get_resource_asset_urls(self, repo_key: str, tag: str, file_name: str) -> dict[str, str]: + """获取资源仓库各源的 release 附件下载地址。 + + :param repo_key: resource_download.repos 中的仓库标识 + :param tag: release 标签 为空时使用 latest + :param file_name: 附件文件名 + :return: 源 ID 到下载地址的映射 + """ + urls: dict[str, str] = {} + for source_id in self.resource_repos.get(repo_key, {}): + base_url = self.get_resource_download_base_url( + repo_key, + tag, + source_id, + ) + if base_url: + urls[source_id] = f'{base_url}/{file_name}' + return urls + + def get_resource_source_candidates( + self, + user_choice: str | None = None, + last_success: str | None = None, + language: str | None = None, + ) -> list[str]: + """获取资源下载的候选源顺序。 + + 顺序为: 用户指定 > 上次成功源 > 按语言推荐 > 其余源(按 YAML 顺序)。 + + :param user_choice: 用户指定的源 ID auto 或 None 表示自动 + :param last_success: 上次下载成功的源 ID + :param language: 当前语言 用于推荐 + :return: 源 ID 列表 + """ + candidates: list[str] = [] + + def _add(source_id: str | None) -> None: + if source_id and source_id in self._resource_sources_by_id and source_id not in candidates: + candidates.append(source_id) + + if user_choice and user_choice != self.AUTO_RESOURCE_SOURCE_VALUE: + _add(user_choice) + _add(last_success) + if language: + _add(self.resource_recommend.get(language)) + for source in self.resource_sources: + _add(source.source_id) + return candidates diff --git a/src/one_dragon_qt/widgets/download_queue_dialog.py b/src/one_dragon_qt/widgets/download_queue_dialog.py index c044740..1bb5d74 100644 --- a/src/one_dragon_qt/widgets/download_queue_dialog.py +++ b/src/one_dragon_qt/widgets/download_queue_dialog.py @@ -197,11 +197,7 @@ def _transfer_text(self) -> str: progress = self.task.progress parts: list[str] = [] if progress.source_id: - repo_config = getattr(self.service.ctx, 'repo_config', None) - source = ( - repo_config.get_resource_source(progress.source_id) - if repo_config is not None else None - ) + source = self.service.ctx.repo_config.get_resource_source(progress.source_id) parts.append(source.label if source is not None else progress.source_id.upper()) if progress.downloaded_bytes: downloaded = format_byte_size(progress.downloaded_bytes) diff --git a/src/script_chainer/context/script_chainer_context.py b/src/script_chainer/context/script_chainer_context.py index f0446f7..d31c59f 100644 --- a/src/script_chainer/context/script_chainer_context.py +++ b/src/script_chainer/context/script_chainer_context.py @@ -9,6 +9,7 @@ from one_dragon.envs.env_config import EnvConfig from one_dragon.envs.ghproxy_service import GhProxyService from one_dragon.envs.project_config import ProjectConfig +from one_dragon.envs.repo_config import RepoConfig from one_dragon.utils import os_utils from one_dragon.utils.log_utils import log from script_chainer.config.run_config import RunConfig @@ -25,7 +26,8 @@ class ScriptChainerContext: def __init__(self): self.project_config: ProjectConfig = ProjectConfig() - self.env_config: EnvConfig = EnvConfig() + self.repo_config: RepoConfig = RepoConfig() + self.env_config: EnvConfig = EnvConfig(self.repo_config) self.custom_config: CustomConfig = CustomConfig() self.gh_proxy_service: GhProxyService = GhProxyService(self.env_config) self.github_update_service: GithubUpdateService = GithubUpdateService(self) diff --git a/src/script_chainer/gui/page/editor_setting_interface.py b/src/script_chainer/gui/page/editor_setting_interface.py index a8f14da..d685fd0 100644 --- a/src/script_chainer/gui/page/editor_setting_interface.py +++ b/src/script_chainer/gui/page/editor_setting_interface.py @@ -13,8 +13,10 @@ setTheme, ) +from one_dragon.base.config.config_item import ConfigItem from one_dragon.custom.custom_config import ThemeEnum -from one_dragon.envs.env_config import ProxyTypeEnum +from one_dragon.envs.env_config import EnvConfig, ProxyTypeEnum +from one_dragon.envs.repo_config import RepoConfig from one_dragon.utils import os_utils from one_dragon.utils.i18_utils import gt from one_dragon.utils.log_utils import log @@ -39,6 +41,21 @@ from script_chainer.context.script_chainer_context import ScriptChainerContext +def build_resource_source_options(env_config: EnvConfig) -> list[ConfigItem]: + """构造资源下载源选项,自动项显示真实的首选源。""" + repo_config = env_config.repo_config + source_id = env_config.last_resource_source or env_config.get_recommended_resource_source() + preferred = repo_config.get_resource_source(source_id) if source_id else None + preferred_reason = gt('上次成功') if env_config.last_resource_source else gt('推荐') + + auto_label = gt('自动') + if preferred is not None: + auto_label = f'{auto_label} · {preferred_reason} {preferred.label}' + options: list[ConfigItem] = [ConfigItem(auto_label, RepoConfig.AUTO_RESOURCE_SOURCE_VALUE)] + options.extend(ConfigItem(source.label, source.source_id) for source in repo_config.resource_sources) + return options + + class GithubUpdateChecker(QThread): check_finished = Signal(bool, str, str, str) @@ -321,6 +338,14 @@ def get_update_group(self) -> SettingCardGroup: self.github_update_opt = GithubUpdateCard(self.ctx) group.addSettingCard(self.github_update_opt) + self.resource_source_opt = ComboBoxSettingCard( + icon=FluentIcon.GLOBE, + title='下载源', + content='自动模式优先使用上次成功源,失败后继续尝试其他源', + options_list=build_resource_source_options(self.ctx.env_config), + ) + group.addSettingCard(self.resource_source_opt) + self.proxy_type_opt = ComboBoxSettingCard( icon=FluentIcon.GLOBE, title='代理类型', @@ -375,6 +400,7 @@ def on_interface_shown(self) -> None: self.privacy_screen_key_input.init_with_adapter( self.ctx.run_config.get_prop_adapter('privacy_screen_close_key') ) + self.resource_source_opt.init_with_adapter(self.ctx.env_config.get_prop_adapter('resource_source')) self.proxy_type_opt.init_with_adapter(self.ctx.env_config.get_prop_adapter('proxy_type')) self.personal_proxy_input.init_with_adapter(self.ctx.env_config.get_prop_adapter('personal_proxy')) self.gh_proxy_url_opt.init_with_adapter(self.ctx.env_config.get_prop_adapter('gh_proxy_url')) diff --git a/src/script_chainer/gui/page/script_setting_cards.py b/src/script_chainer/gui/page/script_setting_cards.py index 7ad0373..afe1713 100644 --- a/src/script_chainer/gui/page/script_setting_cards.py +++ b/src/script_chainer/gui/page/script_setting_cards.py @@ -26,7 +26,10 @@ ScriptChainConfig, ScriptConfig, ) -from script_chainer.gui.page.script_setting_dialogs import ComputerOpEditDialog, ScriptRenameDialog +from script_chainer.gui.page.script_setting_dialogs import ( + ComputerOpEditDialog, + ScriptRenameDialog, +) from script_chainer.gui.page.script_setting_utils import ( show_error, show_success, diff --git a/src/script_chainer/services/github_update_service.py b/src/script_chainer/services/github_update_service.py index 12f7063..8805ac9 100644 --- a/src/script_chainer/services/github_update_service.py +++ b/src/script_chainer/services/github_update_service.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING from one_dragon.base.web.common_downloader import ( - GITHUB_SOURCE_ID, CommonDownloader, CommonDownloaderParam, ) @@ -171,15 +170,14 @@ def _build_opener(self): def _get_downloader_param(self, latest_tag: str, update_dir: Path) -> CommonDownloaderParam: release_zip_name = f'{RELEASE_ZIP_PREFIX}-{latest_tag}.zip' - download_url = ( - f'{self.ctx.project_config.github_homepage}' - f'/releases/download/{latest_tag}/{release_zip_name}' + download_urls = self.ctx.repo_config.get_resource_asset_urls( + 'main', latest_tag, release_zip_name, ) return CommonDownloaderParam( save_file_path=str(update_dir), save_file_name=release_zip_name, - download_urls={GITHUB_SOURCE_ID: download_url}, + download_urls=download_urls, ) def _extract_release(self, zip_path: Path, stage_dir: Path) -> None: diff --git a/src/script_chainer/utils/runner_utils.py b/src/script_chainer/utils/runner_utils.py index 998b7d5..5a441ff 100644 --- a/src/script_chainer/utils/runner_utils.py +++ b/src/script_chainer/utils/runner_utils.py @@ -2,6 +2,7 @@ import os import sys + from one_dragon.utils.os_utils import get_path_under_work_dir diff --git a/src/script_chainer/win_exe/script_runner.py b/src/script_chainer/win_exe/script_runner.py index b744297..53f1c74 100644 --- a/src/script_chainer/win_exe/script_runner.py +++ b/src/script_chainer/win_exe/script_runner.py @@ -15,8 +15,8 @@ from colorama import Fore, Style, init -from one_dragon.utils import cmd_utils from one_dragon.base.config.config_item import get_config_item_from_enum +from one_dragon.utils import cmd_utils from script_chainer.config.run_config import RunConfig from script_chainer.config.script_config import ( CheckDoneMethods, diff --git a/tools/ci/sync_github_release_to_cnb.py b/tools/ci/sync_github_release_to_cnb.py new file mode 100644 index 0000000..90791f4 --- /dev/null +++ b/tools/ci/sync_github_release_to_cnb.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import argparse +import os +import tempfile +from pathlib import Path +from typing import Any, BinaryIO, cast +from urllib.parse import quote + +import requests + +GITHUB_REPOSITORY = 'OneDragon-Anything/OneDragon-ScriptChainer' +CNB_REPOSITORY = 'OneDragon-Anything/OneDragon-ScriptChainer' +GITHUB_API_URL = 'https://api.github.com' +CNB_API_URL = 'https://api.cnb.cool' +CHUNK_SIZE = 1024 * 1024 +PROGRESS_INTERVAL = 64 * 1024 * 1024 + + +class ProgressReader: + """读取上传文件并定期输出进度,避免大文件上传期间流水线无日志。""" + + def __init__(self, path: Path): + self.path: Path = path + self.total: int = path.stat().st_size + self.current: int = 0 + self.next_report: int = PROGRESS_INTERVAL + self.file: BinaryIO = path.open('rb') + + def __len__(self) -> int: + return self.total + + def read(self, size: int = -1) -> bytes: + data = self.file.read(size) + self.current += len(data) + if self.current >= self.next_report or self.current == self.total: + _print_progress('上传', self.path.name, self.current, self.total) + while self.next_report <= self.current: + self.next_report += PROGRESS_INTERVAL + return data + + def close(self) -> None: + self.file.close() + + def __enter__(self) -> ProgressReader: + return self + + def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None: + self.close() + + +def _print_progress(action: str, name: str, current: int, total: int) -> None: + percent = current * 100 / total if total else 100 + print( + f'{action} {name}: {current / 1024 / 1024:.1f} / ' + f'{total / 1024 / 1024:.1f} MiB ({percent:.1f}%)', + flush=True, + ) + + +def _expect_status(response: requests.Response, expected: set[int]) -> None: + if response.status_code in expected: + return + detail = response.text[:2000] + raise RuntimeError(f'请求失败: {response.status_code} {response.url}\n{detail}') + + +def _response_json(response: requests.Response) -> dict[str, Any]: + data = response.json() + if not isinstance(data, dict): + raise RuntimeError(f'接口返回的不是对象: {response.url}') + return cast(dict[str, Any], data) + + +def _build_release_payload( + release: dict[str, Any], + latest_release_tag: str | None, +) -> dict[str, object]: + tag_name = release.get('tag_name') + if not isinstance(tag_name, str) or not tag_name: + raise ValueError('GitHub Release 缺少 tag_name') + + name = release.get('name') + body = release.get('body') + target_commitish = release.get('target_commitish') + prerelease = bool(release.get('prerelease')) + make_latest = not prerelease and tag_name == latest_release_tag + + return { + 'tag_name': tag_name, + 'name': name if isinstance(name, str) and name else tag_name, + 'body': body if isinstance(body, str) else '', + 'draft': False, + 'prerelease': prerelease, + 'target_commitish': target_commitish if isinstance(target_commitish, str) else 'main', + 'make_latest': 'true' if make_latest else 'false', + } + + +def _select_assets(release: dict[str, Any]) -> list[dict[str, Any]]: + assets = release.get('assets') + if not isinstance(assets, list): + raise ValueError('GitHub Release 的 assets 格式无效') + + selected: list[dict[str, Any]] = [] + for asset in assets: + if not isinstance(asset, dict) or asset.get('state') != 'uploaded': + continue + name = asset.get('name') + download_url = asset.get('browser_download_url') + size = asset.get('size') + if not isinstance(name, str) or Path(name).name != name or not name: + raise ValueError(f'非法 Release 附件名: {name!r}') + if not isinstance(download_url, str) or not download_url: + raise ValueError(f'Release 附件缺少下载地址: {name}') + if not isinstance(size, int) or size < 0: + raise ValueError(f'Release 附件大小无效: {name}') + selected.append(cast(dict[str, Any], asset)) + + if not selected: + raise ValueError('GitHub Release 没有可同步的附件') + return selected + + +class ReleaseSynchronizer: + """把一个 GitHub Release 的元数据和附件同步到 CNB。""" + + def __init__(self, cnb_token: str, github_token: str | None = None): + self.cnb_token: str = cnb_token + self.github_session: requests.Session = requests.Session() + self.github_session.headers.update( + { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'OneDragon-ScriptChainer-CNB-Release-Sync', + 'X-GitHub-Api-Version': '2022-11-28', + } + ) + if github_token: + self.github_session.headers['Authorization'] = f'Bearer {github_token}' + + self.cnb_session: requests.Session = requests.Session() + self.cnb_session.headers.update( + { + 'Accept': 'application/vnd.cnb.api+json', + 'Authorization': f'Bearer {cnb_token}', + 'User-Agent': 'OneDragon-ScriptChainer-CNB-Release-Sync', + } + ) + + def get_github_release(self, release_tag: str | None) -> dict[str, Any]: + if release_tag: + encoded_tag = quote(release_tag, safe='') + url = f'{GITHUB_API_URL}/repos/{GITHUB_REPOSITORY}/releases/tags/{encoded_tag}' + else: + url = f'{GITHUB_API_URL}/repos/{GITHUB_REPOSITORY}/releases/latest' + + response = self.github_session.get(url, timeout=60) + _expect_status(response, {200}) + release = _response_json(response) + print(f"获取 GitHub Release: {release.get('tag_name')}", flush=True) + return release + + def ensure_cnb_release( + self, + release: dict[str, Any], + latest_release_tag: str | None, + ) -> dict[str, Any]: + payload = _build_release_payload(release, latest_release_tag) + tag_name = cast(str, payload['tag_name']) + encoded_tag = quote(tag_name, safe='') + release_url = f'{CNB_API_URL}/{CNB_REPOSITORY}/-/releases/tags/{encoded_tag}' + response = self.cnb_session.get(release_url, timeout=60) + + if response.status_code == 404: + create_url = f'{CNB_API_URL}/{CNB_REPOSITORY}/-/releases' + response = self.cnb_session.post(create_url, json=payload, timeout=60) + _expect_status(response, {201}) + result = _response_json(response) + print(f'已创建 CNB Release: {tag_name}', flush=True) + return result + + _expect_status(response, {200}) + result = _response_json(response) + release_id = result.get('id') + if not isinstance(release_id, str) or not release_id: + raise RuntimeError('CNB Release 缺少 id') + + patch_payload = { + key: payload[key] + for key in ('name', 'body', 'draft', 'prerelease', 'make_latest') + } + patch_url = f'{CNB_API_URL}/{CNB_REPOSITORY}/-/releases/{release_id}' + patch_response = self.cnb_session.patch(patch_url, json=patch_payload, timeout=60) + _expect_status(patch_response, {200}) + print(f'已更新现有 CNB Release: {tag_name}', flush=True) + return result + + def download_asset(self, asset: dict[str, Any], destination: Path) -> None: + name = cast(str, asset['name']) + download_url = cast(str, asset['browser_download_url']) + expected_size = cast(int, asset['size']) + next_report = PROGRESS_INTERVAL + + for attempt in range(1, 4): + try: + with self.github_session.get( + download_url, + stream=True, + timeout=(30, 300), + ) as response: + _expect_status(response, {200}) + current = 0 + with destination.open('wb') as output: + for chunk in response.iter_content(chunk_size=CHUNK_SIZE): + if not chunk: + continue + output.write(chunk) + current += len(chunk) + if current >= next_report or current == expected_size: + _print_progress('下载', name, current, expected_size) + while next_report <= current: + next_report += PROGRESS_INTERVAL + + actual_size = destination.stat().st_size + if actual_size != expected_size: + raise RuntimeError( + f'附件大小不一致: {name}, 期望 {expected_size}, 实际 {actual_size}' + ) + return + except (OSError, requests.RequestException, RuntimeError) as exc: + if attempt == 3: + raise + print(f'下载失败,准备重试 ({attempt}/3): {name}: {exc}', flush=True) + next_report = PROGRESS_INTERVAL + + def upload_asset(self, release_id: str, path: Path) -> None: + upload_url_api = ( + f'{CNB_API_URL}/{CNB_REPOSITORY}/-/releases/{release_id}/asset-upload-url' + ) + response = self.cnb_session.post( + upload_url_api, + json={ + 'asset_name': path.name, + 'overwrite': True, + 'size': path.stat().st_size, + }, + timeout=60, + ) + _expect_status(response, {201}) + upload_info = _response_json(response) + upload_url = upload_info.get('upload_url') + verify_url = upload_info.get('verify_url') + if not isinstance(upload_url, str) or not upload_url: + raise RuntimeError(f'CNB 未返回附件上传地址: {path.name}') + + with ProgressReader(path) as reader: + upload_response = requests.put( + upload_url, + headers={ + 'Authorization': f'Bearer {self.cnb_token}', + 'Content-Length': str(reader.total), + }, + data=reader, + timeout=(30, 600), + ) + _expect_status(upload_response, {200, 201, 204}) + + if isinstance(verify_url, str) and verify_url: + verify_response = self.cnb_session.post(verify_url, timeout=60) + _expect_status(verify_response, {200, 201, 204}) + print(f'已上传 CNB Release 附件: {path.name}', flush=True) + + def sync(self, release_tag: str | None) -> None: + release = self.get_github_release(release_tag) + assets = _select_assets(release) + + latest_release_tag: str | None = None + if not bool(release.get('prerelease')): + latest_release = release if release_tag is None else self.get_github_release(None) + latest_release_tag_value = latest_release.get('tag_name') + if not isinstance(latest_release_tag_value, str) or not latest_release_tag_value: + raise ValueError('GitHub 最新正式版缺少 tag_name') + latest_release_tag = latest_release_tag_value + + cnb_release = self.ensure_cnb_release(release, latest_release_tag) + release_id = cnb_release.get('id') + if not isinstance(release_id, str) or not release_id: + raise RuntimeError('CNB Release 缺少 id') + + with tempfile.TemporaryDirectory(prefix='zzz-od-cnb-release-') as temp_dir: + temp_path = Path(temp_dir) + for index, asset in enumerate(assets, start=1): + name = cast(str, asset['name']) + path = temp_path / name + print(f'[{index}/{len(assets)}] 开始同步: {name}', flush=True) + try: + self.download_asset(asset, path) + self.upload_asset(release_id, path) + finally: + path.unlink(missing_ok=True) + + print(f"CNB Release 同步完成: {release.get('tag_name')}", flush=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description='同步 GitHub Release 到 CNB Release') + parser.add_argument('--release-tag', help='指定 GitHub Release 标签;省略时同步最新正式版') + parser.add_argument('--cnb-token', help='CNB 访问令牌,默认读取 CNB_TOKEN') + parser.add_argument('--github-token', help='GitHub 访问令牌,默认读取 GITHUB_TOKEN') + args = parser.parse_args() + + cnb_token = args.cnb_token or os.environ.get('CNB_TOKEN') + if not cnb_token: + parser.error('缺少 CNB Token,请传入 --cnb-token 或设置 CNB_TOKEN') + + github_token = args.github_token or os.environ.get('GITHUB_TOKEN') + synchronizer = ReleaseSynchronizer(cnb_token, github_token) + synchronizer.sync(args.release_tag) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) From da6a4d65512d776279f0b0224e18ae202a498def Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:12:53 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=E8=BF=98=E5=8E=9F=E6=97=A0?= =?UTF-8?q?=E5=85=B3=E6=96=87=E4=BB=B6=E7=9A=84=20import=20=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/script_chainer/gui/page/script_setting_cards.py | 5 +---- src/script_chainer/utils/runner_utils.py | 1 - src/script_chainer/win_exe/script_runner.py | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/script_chainer/gui/page/script_setting_cards.py b/src/script_chainer/gui/page/script_setting_cards.py index afe1713..7ad0373 100644 --- a/src/script_chainer/gui/page/script_setting_cards.py +++ b/src/script_chainer/gui/page/script_setting_cards.py @@ -26,10 +26,7 @@ ScriptChainConfig, ScriptConfig, ) -from script_chainer.gui.page.script_setting_dialogs import ( - ComputerOpEditDialog, - ScriptRenameDialog, -) +from script_chainer.gui.page.script_setting_dialogs import ComputerOpEditDialog, ScriptRenameDialog from script_chainer.gui.page.script_setting_utils import ( show_error, show_success, diff --git a/src/script_chainer/utils/runner_utils.py b/src/script_chainer/utils/runner_utils.py index 5a441ff..998b7d5 100644 --- a/src/script_chainer/utils/runner_utils.py +++ b/src/script_chainer/utils/runner_utils.py @@ -2,7 +2,6 @@ import os import sys - from one_dragon.utils.os_utils import get_path_under_work_dir diff --git a/src/script_chainer/win_exe/script_runner.py b/src/script_chainer/win_exe/script_runner.py index 53f1c74..b744297 100644 --- a/src/script_chainer/win_exe/script_runner.py +++ b/src/script_chainer/win_exe/script_runner.py @@ -15,8 +15,8 @@ from colorama import Fore, Style, init -from one_dragon.base.config.config_item import get_config_item_from_enum from one_dragon.utils import cmd_utils +from one_dragon.base.config.config_item import get_config_item_from_enum from script_chainer.config.run_config import RunConfig from script_chainer.config.script_config import ( CheckDoneMethods,