Added download settings and optimized the downloader system with concurrency.

This commit is contained in:
2026-09-03 18:26:10 +02:00
parent ba079ef6fa
commit c61254e4a9
5 changed files with 954 additions and 84 deletions

View File

@@ -63,6 +63,8 @@ from .news import (
fetch_official_logo,
read_cached_broadcast,
read_cached_news,
read_cached_news_image,
read_cached_official_logo,
)
from .overlay import RuntimeCompatibility, inspect_runtime_compatibility
from .paths import AppPaths
@@ -120,6 +122,14 @@ PROTON_SYNC_LABELS: dict[str, str] = {
"Fsync": "fsync",
"Esync": "esync",
}
DOWNLOAD_SPEED_PRESETS_MBPS: dict[str, float | None] = {
"Unlimited": None,
"5 MB/s": 5.0,
"10 MB/s": 10.0,
"25 MB/s": 25.0,
"50 MB/s": 50.0,
}
DOWNLOAD_SPEED_CUSTOM_LABEL = "Custom…"
NEWS_THREE_COLUMN_WIDTH = 900
NEWS_TWO_COLUMN_WIDTH = 620
STACKED_CONTROLS_WIDTH = 860
@@ -305,6 +315,62 @@ def setting_value(label: str, choices: Mapping[str, str]) -> str:
raise ValueError(f"unsupported setting choice: {label}") from error
def download_speed_setting_label(limit_mbps: float | None) -> str:
for label, preset in DOWNLOAD_SPEED_PRESETS_MBPS.items():
if preset == limit_mbps:
return label
return DOWNLOAD_SPEED_CUSTOM_LABEL
def download_speed_setting_value(label: str, custom_value: str) -> float | None:
if label in DOWNLOAD_SPEED_PRESETS_MBPS:
return DOWNLOAD_SPEED_PRESETS_MBPS[label]
if label != DOWNLOAD_SPEED_CUSTOM_LABEL:
raise ValueError(f"unsupported download speed choice: {label}")
try:
value = float(custom_value.strip())
except ValueError as error:
raise ValueError("custom download speed must be a number in MB/s") from error
if not math.isfinite(value) or value <= 0 or value > 10_000:
raise ValueError(
"custom download speed must be greater than 0 and at most 10000 MB/s"
)
return value
def download_activity_metadata(
*,
current_server: str | None,
retry_count: int,
retry_reason: str | None,
active_workers: int,
target_workers: int,
) -> str:
"""Render compact download details for the existing activity heading."""
if retry_count < 0 or active_workers < 0 or target_workers < 0:
raise ValueError("download activity counts must not be negative")
parts: list[str] = []
if current_server:
parts.append(f"CDN: {_ellipsize(current_server, 28)}")
if target_workers:
parts.append(f"Workers: {active_workers}/{target_workers}")
retry_text = f"Retries: {retry_count}"
if retry_reason:
retry_text += f" ({_ellipsize(retry_reason, 26, keep_tail=True)})"
parts.append(retry_text)
return " · ".join(parts)
def _ellipsize(value: str, limit: int, *, keep_tail: bool = False) -> str:
normalized = " ".join(value.split())
if len(normalized) <= limit:
return normalized
if keep_tail:
return f"…{normalized[-(limit - 1):]}"
return f"{normalized[:limit - 1]}…"
def call_optional(callback: Callable[..., None] | None, *arguments: object) -> None:
if callback is not None:
callback(*arguments)
@@ -1813,6 +1879,18 @@ def run_dashboard(paths: AppPaths) -> None:
progress_text = tk.StringVar(value="Click Play to start your journey")
speed_text = tk.StringVar(value="")
client_activity_text = tk.StringVar(value="")
download_context_text = tk.StringVar(value="")
activity_meta_text = tk.StringVar(value="")
def refresh_activity_meta_text() -> None:
activity_meta_text.set(
download_context_text.get() or client_activity_text.get()
)
def set_download_context_text(value: str) -> None:
download_context_text.set(value)
refresh_activity_meta_text()
activity_heading = ttk.Frame(activity_copy, style="Surface.TFrame")
activity_heading.pack(fill="x")
ttk.Label(
@@ -1822,7 +1900,7 @@ def run_dashboard(paths: AppPaths) -> None:
).pack(side="left", anchor="w")
ttk.Label(
activity_heading,
textvariable=client_activity_text,
textvariable=activity_meta_text,
style="Muted.TLabel",
).pack(side="right", anchor="e")
progress_bar = ttk.Progressbar(
@@ -1839,7 +1917,7 @@ def run_dashboard(paths: AppPaths) -> None:
progress_details,
textvariable=speed_text,
style="Speed.TLabel",
width=15,
width=28,
anchor="e",
).pack(side="right")
ttk.Label(
@@ -1857,6 +1935,24 @@ def run_dashboard(paths: AppPaths) -> None:
).pack(side="left", fill="y", padx=(0, 8))
server_status_content = ttk.Frame(server_status_row, style="Surface.TFrame")
server_status_content.pack(side="left", fill="y", anchor="w")
log_toggle = tk.Button(
server_status_row,
text=">_",
background="#191311",
foreground="#f8fafc",
activebackground="#24170f",
activeforeground="#ffffff",
font=("Monospace", 8, "bold"),
padx=3,
pady=1,
borderwidth=0,
highlightthickness=1,
highlightbackground="#191311",
highlightcolor="#f4bd61",
relief="flat",
takefocus=True,
)
log_toggle.pack(side="right", anchor="e", padx=(8, 0))
server_status_debug_text = tk.StringVar(value="")
ttk.Label(
server_status_row,
@@ -1969,26 +2065,6 @@ def run_dashboard(paths: AppPaths) -> None:
)
log.pack(fill="both", expand=True, padx=10, pady=10)
activity_window.withdraw()
activity_footer = ttk.Frame(frame, style="Root.TFrame")
activity_footer.pack(fill="x", pady=(3, 0))
log_toggle = tk.Button(
activity_footer,
text=">_",
background="#0c0908",
foreground="#f8fafc",
activebackground="#24170f",
activeforeground="#ffffff",
font=("Monospace", 8, "bold"),
padx=3,
pady=2,
borderwidth=0,
highlightthickness=2,
highlightbackground="#0c0908",
highlightcolor="#f4bd61",
relief="flat",
takefocus=True,
)
log_toggle.pack(side="left", anchor="sw")
events: queue.Queue[tuple[str, object]] = queue.Queue()
progress_updates = ProgressCoalescer()
@@ -2111,28 +2187,67 @@ def run_dashboard(paths: AppPaths) -> None:
if phase == "manifest":
transfer_rate.reset()
speed_text.set("")
set_download_context_text("")
set_operation_stage(1, 3, "Checking official CDN manifests…")
return
progress_bar.stop()
progress_bar.configure(mode="determinate")
if bytes_total:
progress_bar.configure(maximum=bytes_total, value=min(bytes_completed, bytes_total))
bytes_remaining = _as_int(event.get("bytes_remaining"))
if "bytes_remaining" not in event:
bytes_remaining = max(0, bytes_total - bytes_completed)
percent = min(100, round((bytes_completed / bytes_total) * 100))
operation_progress_detail = (
f"{files_completed}/{files_total} files · "
f"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)}"
f"{percent}% · {files_completed:,}/{files_total:,} files · "
f"{_format_bytes(bytes_completed)} / {_format_bytes(bytes_total)} · "
f"{_format_bytes(bytes_remaining)} remaining"
)
if phase == "downloading":
bytes_transferred = _as_int(event.get("bytes_transferred"))
rate = transfer_rate.update(bytes_transferred)
speed_text.set(format_transfer_rate(rate) if rate is not None else "0 B/s")
estimated_rate = transfer_rate.update(bytes_transferred)
reported_rate = _as_float(
event.get("transfer_rate_bytes_per_second")
)
rate = reported_rate if reported_rate is not None else estimated_rate
eta_seconds = _as_float(event.get("eta_seconds"))
if eta_seconds is None and rate is not None and rate > 0:
eta_seconds = bytes_remaining / rate
speed_text.set(
f"{format_transfer_rate(rate) if rate is not None else '0 B/s'} · "
f"ETA {format_eta(eta_seconds)}"
)
current_server = event.get("current_server")
active_workers = _as_int(event.get("active_workers"))
target_workers = _as_int(event.get("target_workers"))
retry_reason = event.get("retry_reason")
set_download_context_text(
download_activity_metadata(
current_server=(
current_server
if isinstance(current_server, str) and current_server
else None
),
retry_count=_as_int(event.get("retry_count")),
retry_reason=(
retry_reason
if isinstance(retry_reason, str) and retry_reason
else None
),
active_workers=active_workers,
target_workers=target_workers,
)
)
else:
transfer_rate.reset()
speed_text.set("")
set_download_context_text("")
else:
progress_bar.configure(maximum=3, value=3)
operation_progress_detail = "Game files are already up to date."
transfer_rate.reset()
speed_text.set("")
set_download_context_text("")
progress_text.set(activity_text())
if isinstance(current_file, str) and current_file:
status.set(
@@ -2159,6 +2274,7 @@ def run_dashboard(paths: AppPaths) -> None:
operation_progress_detail = None
transfer_rate.reset()
speed_text.set("")
set_download_context_text("")
progress_bar.stop()
progress_bar.configure(
mode="determinate",
@@ -2370,6 +2486,7 @@ def run_dashboard(paths: AppPaths) -> None:
progress_updates.clear()
transfer_rate.reset()
speed_text.set("")
set_download_context_text("")
set_busy(True)
status.set(label)
progress_text.set("Waiting for the operation to report progress…")
@@ -2971,7 +3088,11 @@ def run_dashboard(paths: AppPaths) -> None:
)
def worker(news_article: NewsArticle) -> None:
image = fetch_news_image(news_article, paths.news_image_cache)
image = (
read_cached_news_image(news_article, paths.news_image_cache)
if startup_settings.low_bandwidth_mode
else fetch_news_image(news_article, paths.news_image_cache)
)
events.put(("news_image", (news_article.id, image)))
for article in image_articles:
@@ -3087,7 +3208,11 @@ def run_dashboard(paths: AppPaths) -> None:
def load_official_logo() -> None:
def worker() -> None:
image = fetch_official_logo(paths.logo_cache)
image = (
read_cached_official_logo(paths.logo_cache)
if startup_settings.low_bandwidth_mode
else fetch_official_logo(paths.logo_cache)
)
events.put(("logo_image", image))
threading.Thread(target=worker, name="ptlinux-logo", daemon=True).start()
@@ -3529,6 +3654,7 @@ def run_dashboard(paths: AppPaths) -> None:
latest_installation_status = installation
additional_client_ready = game_clients.running_count > 0 and installation.installed
client_activity_text.set(game_client_activity_text(game_clients.running_count))
refresh_activity_meta_text()
play_button.configure(
text="PLAY" if additional_client_ready else installation.action.value
)
@@ -3685,16 +3811,42 @@ def run_dashboard(paths: AppPaths) -> None:
)
events.put(("version", snapshot))
threading.Thread(target=broadcast_worker, name="ptlinux-broadcast", daemon=True).start()
threading.Thread(target=news_worker, name="ptlinux-news", daemon=True).start()
threading.Thread(target=patches_worker, name="ptlinux-patches", daemon=True).start()
if startup_settings.low_bandwidth_mode:
append_log(
"Low-bandwidth mode: using cached news, broadcasts, patch notes, "
"and artwork."
)
for component, message in (
("broadcast", "Cached broadcast ready"),
("news", "Cached news ready"),
("patches", "Cached patch notes ready"),
("launcher_update", "Cached launcher update status ready"),
):
mark_startup_component_ready(component, message)
else:
threading.Thread(
target=broadcast_worker,
name="ptlinux-broadcast",
daemon=True,
).start()
threading.Thread(
target=news_worker,
name="ptlinux-news",
daemon=True,
).start()
threading.Thread(
target=patches_worker,
name="ptlinux-patches",
daemon=True,
).start()
threading.Thread(
target=server_status_worker,
name="ptlinux-server-status",
daemon=True,
).start()
threading.Thread(target=version_worker, name="ptlinux-version", daemon=True).start()
refresh_launcher_update_check()
if not startup_settings.low_bandwidth_mode:
refresh_launcher_update_check()
def start_remote_content_refresh() -> None:
nonlocal remote_refresh_started
@@ -3723,6 +3875,7 @@ def run_dashboard(paths: AppPaths) -> None:
progress_updates.clear()
transfer_rate.reset()
speed_text.set("")
set_download_context_text("")
progress_bar.stop()
close_game_preloader()
if not ready:
@@ -3756,6 +3909,7 @@ def run_dashboard(paths: AppPaths) -> None:
progress_updates.clear()
transfer_rate.reset()
speed_text.set("")
set_download_context_text("")
progress_bar.stop()
close_game_preloader()
root.deiconify()
@@ -4556,7 +4710,10 @@ def run_dashboard(paths: AppPaths) -> None:
).pack(anchor="w", pady=(1, 0))
tk.Label(
header_copy,
text="Tune launcher accessibility, the game window, and runtime preferences.",
text=(
"Tune launcher accessibility, downloads, the game window, and runtime "
"preferences."
),
background="#24170f",
foreground="#c9baaa",
font=("Sans", 9),
@@ -4586,6 +4743,21 @@ def run_dashboard(paths: AppPaths) -> None:
)
proton_path_value = tk.StringVar(value=current_settings.proton_path or "")
game_source_value = tk.StringVar(value=current_settings.game_source or "")
download_speed_value = tk.StringVar(
value=download_speed_setting_label(
current_settings.download_speed_limit_mbps
)
)
custom_download_speed_value = tk.StringVar(
value=(
f"{current_settings.download_speed_limit_mbps:g}"
if current_settings.download_speed_limit_mbps is not None
else "5"
)
)
low_bandwidth_value = tk.BooleanVar(
value=current_settings.low_bandwidth_mode
)
def add_choice(
parent: ttk.LabelFrame,
@@ -4673,6 +4845,10 @@ def run_dashboard(paths: AppPaths) -> None:
def save_current_settings() -> None:
try:
download_speed_limit = download_speed_setting_value(
download_speed_value.get(),
custom_download_speed_value.get(),
)
updated_settings = update_settings(
paths,
borderless=borderless_value.get(),
@@ -4685,6 +4861,8 @@ def run_dashboard(paths: AppPaths) -> None:
),
proton_path=proton_path_value.get().strip() or None,
game_source=game_source_value.get().strip() or None,
download_speed_limit_mbps=download_speed_limit,
low_bandwidth_mode=low_bandwidth_value.get(),
)
except (OSError, ValueError) as error:
show_themed_dialog(
@@ -4695,14 +4873,22 @@ def run_dashboard(paths: AppPaths) -> None:
window_icon=window_icon,
)
return
saved_limit = (
f"{updated_settings.download_speed_limit_mbps:g} MB/s"
if updated_settings.download_speed_limit_mbps is not None
else "unlimited"
)
append_log(
f"Saved launcher settings to {paths.settings_file}: "
f"runtime={updated_settings.runtime}, "
f"proton_sync={updated_settings.proton_sync}"
f"proton_sync={updated_settings.proton_sync}, "
f"download_limit={saved_limit}, "
f"low_bandwidth={updated_settings.low_bandwidth_mode}"
)
status.set("Settings saved.")
progress_text.set(
"Game settings apply on the next launch; launcher scale applies after restart."
"Game and download settings apply to the next operation; launcher scale "
"and cached-content mode apply after restart."
)
if show_dashboard_debug:
refresh_dashboard_debug_disk_state()
@@ -4782,13 +4968,97 @@ def run_dashboard(paths: AppPaths) -> None:
tuple(PROTON_SYNC_LABELS),
)
downloads_card = ttk.LabelFrame(
content,
text="DOWNLOADS",
style="SettingsCard.TLabelframe",
padding=14,
)
downloads_card.grid(
row=1,
column=0,
columnspan=2,
sticky="ew",
pady=(12, 0),
)
downloads_card.columnconfigure(0, weight=1)
downloads_card.columnconfigure(1, weight=1)
downloads_card.columnconfigure(2, weight=1)
ttk.Label(
downloads_card,
text=(
"Concurrency adapts to CDN latency, retries, transfer and disk speed, "
"CPU load, and the remaining files."
),
style="SettingsHint.TLabel",
).grid(row=0, column=0, columnspan=3, sticky="w", pady=(0, 9))
ttk.Checkbutton(
downloads_card,
text="Low-bandwidth mode (cached news, up to 2 workers and 5 MB/s)",
variable=low_bandwidth_value,
style="Settings.TCheckbutton",
).grid(row=1, column=0, sticky="w", padx=(0, 16))
ttk.Label(
downloads_card,
text="Speed limit",
style="SettingsText.TLabel",
).grid(row=1, column=1, sticky="w")
ttk.Label(
downloads_card,
text="Custom MB/s",
style="SettingsText.TLabel",
).grid(row=1, column=2, sticky="w", padx=(8, 0))
download_speed_selector = ttk.Combobox(
downloads_card,
textvariable=download_speed_value,
values=tuple(DOWNLOAD_SPEED_PRESETS_MBPS) + (
DOWNLOAD_SPEED_CUSTOM_LABEL,
),
state="readonly",
style="Settings.TCombobox",
)
download_speed_selector.grid(
row=2,
column=1,
sticky="ew",
pady=(3, 0),
)
custom_download_speed = ttk.Entry(
downloads_card,
textvariable=custom_download_speed_value,
style="Settings.TEntry",
width=12,
)
custom_download_speed.grid(
row=2,
column=2,
sticky="ew",
padx=(8, 0),
pady=(3, 0),
)
def update_custom_download_state(_event: object | None = None) -> None:
custom_download_speed.configure(
state=(
"normal"
if download_speed_value.get() == DOWNLOAD_SPEED_CUSTOM_LABEL
else "disabled"
)
)
download_speed_selector.bind(
"<<ComboboxSelected>>",
update_custom_download_state,
)
update_custom_download_state()
overrides_card = ttk.LabelFrame(
content,
text="OPTIONAL OVERRIDES",
style="SettingsCard.TLabelframe",
padding=14,
)
overrides_card.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(12, 0))
overrides_card.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0))
overrides_card.columnconfigure(0, weight=1)
ttk.Label(
overrides_card,
@@ -4813,7 +5083,7 @@ def run_dashboard(paths: AppPaths) -> None:
)
footer = ttk.Frame(content, style="SettingsFooter.TFrame", padding=(12, 9))
footer.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0))
footer.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(12, 0))
footer.columnconfigure(0, weight=1)
ttk.Label(
footer,
@@ -4841,7 +5111,7 @@ def run_dashboard(paths: AppPaths) -> None:
dialog.update_idletasks()
settings_width, settings_height = fitted_window_size(
842,
760,
880,
scale=launcher_ui_scale,
screen_width=dialog.winfo_screenwidth(),
screen_height=dialog.winfo_screenheight(),
@@ -5037,6 +5307,13 @@ def _as_int(value: object) -> int:
return value if isinstance(value, int) and value >= 0 else 0
def _as_float(value: object) -> float | None:
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
number = float(value)
return number if math.isfinite(number) and number >= 0 else None
def dashboard_poll_interval(
*,
operation_active: bool,
@@ -5064,3 +5341,16 @@ def _format_bytes(value: int) -> str:
def format_transfer_rate(bytes_per_second: float) -> str:
return f"{_format_bytes(max(0, round(bytes_per_second)))}/s"
def format_eta(seconds: float | None) -> str:
if seconds is None or not math.isfinite(seconds) or seconds < 0:
return "calculating…"
total_seconds = max(0, math.ceil(seconds))
hours, remainder = divmod(total_seconds, 60 * 60)
minutes, seconds = divmod(remainder, 60)
if hours:
return f"{hours}h {minutes:02d}m"
if minutes:
return f"{minutes}m {seconds:02d}s"
return f"{seconds}s"