Run library, checks and downloads in background threads with parallel fetching

This commit is contained in:
2026-06-06 09:52:45 +02:00
parent 16d5c2857d
commit 13e4e14bac
19 changed files with 1201 additions and 618 deletions
+80 -7
View File
@@ -1,11 +1,14 @@
"""GOG API client for fetching game library and installer info."""
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import unquote, urlparse
import requests
from loguru import logger
from src.auth import AuthManager
from src.constants import APP_NAME, VERSION
from src.models import BonusContent, InstallerInfo, InstallerPlatform, InstallerType, OwnedGame
GOG_API = "https://api.gog.com"
@@ -15,10 +18,27 @@ GOG_EMBED = "https://embed.gog.com"
class GogApi:
"""Handles communication with the GOG API."""
PRODUCT_FETCH_WORKERS = 16
def __init__(self, auth: AuthManager) -> None:
self.auth = auth
self.session = requests.Session()
self.session.headers.update({"User-Agent": "GOGUpdater/0.1"})
self.session.headers.update({"User-Agent": f"{APP_NAME}/{VERSION}"})
# Larger connection pool so concurrent product fetches don't block each other.
adapter = requests.adapters.HTTPAdapter(
pool_connections=self.PRODUCT_FETCH_WORKERS,
pool_maxsize=self.PRODUCT_FETCH_WORKERS,
)
self.session.mount("https://", adapter)
self.session.mount("http://", adapter)
# Caches to avoid refetching the same data repeatedly within a session.
self._product_cache: dict[str, dict] = {}
self._owned_ids_cache: list[int] | None = None
def clear_cache(self) -> None:
"""Drop cached product info and owned IDs (call before a fresh refresh)."""
self._product_cache.clear()
self._owned_ids_cache = None
def _ensure_auth(self) -> bool:
token = self.auth.access_token
@@ -30,17 +50,25 @@ class GogApi:
def get_owned_game_ids(self) -> list[int]:
"""Return list of owned game IDs."""
if self._owned_ids_cache is not None:
logger.debug(f"get_owned_game_ids: cache hit ({len(self._owned_ids_cache)} ids)")
return self._owned_ids_cache
if not self._ensure_auth():
logger.error("get_owned_game_ids: not authenticated")
return []
logger.debug(f"get_owned_game_ids: requesting {GOG_EMBED}/user/data/games")
try:
response = self.session.get(f"{GOG_EMBED}/user/data/games", timeout=15)
except (requests.ConnectionError, requests.Timeout):
logger.error("Failed to fetch owned games — network error")
except (requests.ConnectionError, requests.Timeout) as e:
logger.error(f"Failed to fetch owned games — network error: {e}")
return []
if not response.ok:
logger.error(f"Failed to fetch owned games — HTTP {response.status_code}")
return []
return response.json().get("owned", [])
owned = response.json().get("owned", [])
logger.info(f"get_owned_game_ids: received {len(owned)} owned IDs")
self._owned_ids_cache = owned
return owned
def get_game_details(self, game_id: int | str) -> dict | None:
"""Fetch game details including download links."""
@@ -61,7 +89,12 @@ class GogApi:
def get_product_info(self, game_id: int | str) -> dict | None:
"""Fetch product info with downloads and DLC expansions."""
key = str(game_id)
cached = self._product_cache.get(key)
if cached is not None:
return cached
if not self._ensure_auth():
logger.error(f"get_product_info({game_id}): not authenticated")
return None
try:
response = self.session.get(
@@ -69,13 +102,53 @@ class GogApi:
params={"expand": "downloads,expanded_dlcs"},
timeout=15,
)
except (requests.ConnectionError, requests.Timeout):
logger.error(f"Failed to fetch product info for {game_id} — network error")
except (requests.ConnectionError, requests.Timeout) as e:
logger.error(f"Failed to fetch product info for {game_id} — network error: {e}")
return None
if not response.ok:
logger.error(f"Failed to fetch product info for {game_id} — HTTP {response.status_code}")
return None
return response.json()
product = response.json()
self._product_cache[key] = product
logger.debug(f"get_product_info({game_id}): OK")
return product
def get_products(
self,
game_ids: list[int] | list[str],
progress: Callable[[int, int], None] | None = None,
) -> dict[str, dict]:
"""Fetch product info for many games in parallel.
Returns a dict keyed by str(game_id); games whose info could not be
fetched are simply absent. `progress(done, total)` is called as each
request completes (may be invoked from worker threads).
"""
total = len(game_ids)
results: dict[str, dict] = {}
if not total:
return results
logger.info(f"get_products: fetching {total} products with {self.PRODUCT_FETCH_WORKERS} workers")
done = 0
with ThreadPoolExecutor(max_workers=self.PRODUCT_FETCH_WORKERS) as executor:
futures = {executor.submit(self.get_product_info, gid): gid for gid in game_ids}
for future in as_completed(futures):
gid = futures[future]
try:
product = future.result()
except Exception as e: # defensive — keep going if one game fails
logger.error(f"Error fetching product {gid}: {e}")
product = None
if product is not None:
results[str(gid)] = product
done += 1
if done % 10 == 0 or done == total:
logger.info(f"get_products: {done}/{total} fetched")
if progress:
progress(done, total)
logger.info(f"get_products: done, {len(results)}/{total} succeeded")
return results
def get_owned_games(self) -> list[OwnedGame]:
"""Fetch list of owned games with titles."""
+5 -3
View File
@@ -7,6 +7,9 @@ from pathlib import Path
import requests
from loguru import logger
from src.constants import APP_NAME, VERSION
from src.fileutil import atomic_write_text
GOG_AUTH_URL = "https://auth.gog.com"
CLIENT_ID = "46899977096215655"
CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"
@@ -27,7 +30,7 @@ class AuthManager:
self.auth_file = config_dir / "auth.json"
self.credentials: dict = {}
self.session = requests.Session()
self.session.headers.update({"User-Agent": "GOGUpdater/0.1"})
self.session.headers.update({"User-Agent": f"{APP_NAME}/{VERSION}"})
self._load()
def _load(self) -> None:
@@ -39,8 +42,7 @@ class AuthManager:
self.credentials = {}
def _save(self) -> None:
self.config_dir.mkdir(parents=True, exist_ok=True)
self.auth_file.write_text(json.dumps(self.credentials, indent=2), encoding="utf-8")
atomic_write_text(self.auth_file, json.dumps(self.credentials, indent=2))
@property
def is_logged_in(self) -> bool:
+10 -11
View File
@@ -5,6 +5,7 @@ from pathlib import Path
from loguru import logger
from src.fileutil import atomic_write_text
from src.models import DownloadedInstaller, GameRecord, GameSettings, InstallerType, LANGUAGE_NAMES, PruneStrategy, language_folder_name
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "gogupdater"
@@ -54,7 +55,6 @@ class AppConfig:
logger.warning("Failed to read config.json, using defaults")
def save(self) -> None:
self.config_dir.mkdir(parents=True, exist_ok=True)
data = {
"windows_path": self.windows_path,
"linux_path": self.linux_path,
@@ -71,7 +71,7 @@ class AppConfig:
"prune_keep_count": self.prune_keep_count,
"prune_strategy": self.prune_strategy.value,
}
self.config_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
atomic_write_text(self.config_file, json.dumps(data, indent=2))
def is_game_managed(self, game_id: str) -> bool:
return game_id in self.managed_game_ids
@@ -140,9 +140,8 @@ class MetadataStore:
logger.warning(f"Failed to read {self.metadata_file}, starting fresh")
def save(self) -> None:
self.base_path.mkdir(parents=True, exist_ok=True)
data = {"games": {gid: record.to_dict() for gid, record in self.games.items()}}
self.metadata_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
atomic_write_text(self.metadata_file, json.dumps(data, indent=2))
def get_game(self, game_id: str) -> GameRecord | None:
return self.games.get(game_id)
@@ -300,21 +299,21 @@ class MetadataStore:
return removed
def scan_existing_installers(self, title_to_id: dict[str, str]) -> int:
def scan_existing_installers(self, title_to_id: dict[str, str]) -> list[str]:
"""Scan directory for existing installers and populate metadata.
Expects structure: base_path/GameName/version/[Language/]installer_file
title_to_id maps game titles (folder names) to GOG game IDs.
Returns number of games detected.
Returns list of game IDs that were newly detected.
"""
if not self.base_path.is_dir():
logger.warning(f"Scan path does not exist: {self.base_path}")
return 0
return []
# Reverse language map: folder name -> code
lang_name_to_code: dict[str, str] = {v: k for k, v in LANGUAGE_NAMES.items()}
detected = 0
detected_ids: list[str] = []
for game_dir in sorted(self.base_path.iterdir()):
if not game_dir.is_dir() or game_dir.name.startswith("."):
continue
@@ -390,9 +389,9 @@ class MetadataStore:
installers=installers,
)
self.games[game_id] = record
detected += 1
detected_ids.append(game_id)
logger.info(f"Scan: detected '{game_name}' with {len(installers)} installer(s), version {latest_version}")
if detected:
if detected_ids:
self.save()
return detected
return detected_ids
+23 -6
View File
@@ -99,9 +99,18 @@ class InstallerDownloader:
callback.on_finished(False, f"HTTP {response.status_code}")
return False
total_size = int(response.headers.get("content-length", 0)) + existing_size
downloaded = existing_size
mode = "ab" if existing_size > 0 else "wb"
# Only append if the server honored the Range request (206). A plain 200
# means the body starts from byte 0, so appending would corrupt the file.
if existing_size > 0 and response.status_code == 206:
downloaded = existing_size
mode = "ab"
total_size = int(response.headers.get("content-length", 0)) + existing_size
else:
if existing_size > 0:
logger.warning(f"Server ignored Range for {real_filename}, restarting download")
downloaded = 0
mode = "wb"
total_size = int(response.headers.get("content-length", 0))
last_time = time.monotonic()
last_downloaded = downloaded
@@ -194,9 +203,17 @@ class InstallerDownloader:
callback.on_finished(False, f"HTTP {response.status_code}")
return False
total_size = int(response.headers.get("content-length", 0)) + existing_size
downloaded = existing_size
mode = "ab" if existing_size > 0 else "wb"
# Only append if the server honored the Range request (206).
if existing_size > 0 and response.status_code == 206:
downloaded = existing_size
mode = "ab"
total_size = int(response.headers.get("content-length", 0)) + existing_size
else:
if existing_size > 0:
logger.warning(f"Server ignored Range for {real_filename}, restarting download")
downloaded = 0
mode = "wb"
total_size = int(response.headers.get("content-length", 0))
last_time = time.monotonic()
last_downloaded = downloaded
+26
View File
@@ -0,0 +1,26 @@
"""Small filesystem helpers."""
import os
import tempfile
from pathlib import Path
def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
"""Write text to a file atomically.
Writes to a temporary file in the same directory and then renames it over
the target. This prevents a crash mid-write from corrupting the existing
file (config.json, metadata, auth tokens).
"""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding=encoding) as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
except BaseException:
tmp_path.unlink(missing_ok=True)
raise
+51 -15
View File
@@ -13,10 +13,14 @@ from PySide6.QtWidgets import (
)
from loguru import logger
from collections.abc import Callable
from typing import cast
from src.api import GogApi
from src.config import AppConfig
from src.models import InstallerPlatform
from src.ui.dialog_game_settings import GameSettingsDialog
from src.ui.workers import FetchWorker, run_on_thread
COL_TITLE = 0
COL_ID = 1
@@ -44,6 +48,7 @@ class LibraryTab(QWidget):
self.api = api
self.config = config
self._updating = False
self._active_threads: list = []
# store games_data per platform for re-populating after settings change
self._games_data: dict[InstallerPlatform, list[tuple[str, str, list[tuple[str, str]]]]] = {
InstallerPlatform.WINDOWS: [],
@@ -81,34 +86,47 @@ class LibraryTab(QWidget):
self.platform_tabs.setTabVisible(1, bool(self.config.linux_path))
def refresh_library(self) -> None:
"""Fetch owned games from GOG API and populate both platform trees."""
"""Fetch owned games from GOG API (in a background thread) and populate trees."""
if any(thread.isRunning() for thread, _ in self._active_threads):
return # a refresh is already in progress
self.status_label.setText("Loading library...")
self.tree_windows.clear()
self.tree_linux.clear()
self._update_platform_tab_visibility()
logger.info("Refreshing game library")
self.api.clear_cache()
# TODO: Run in a thread to avoid blocking the GUI
worker = FetchWorker(self._fetch_library_data)
worker.result.connect(self._on_library_fetched)
worker.failed.connect(self._on_library_failed)
worker.progress.connect(self._on_library_progress)
run_on_thread(self, worker)
logger.debug("refresh_library: worker dispatched")
def _fetch_library_data(self, progress: Callable[[int, int], None]) -> object:
"""Network fetch — runs on a worker thread. Returns None if not logged in."""
logger.debug("_fetch_library_data: begin (worker thread)")
owned_ids = self.api.get_owned_game_ids()
if not owned_ids:
logger.warning("No owned games found or not logged in")
self.status_label.setText("No games found or not logged in.")
return
logger.warning("_fetch_library_data: no owned IDs returned")
return None
logger.info(f"Fetching product info for {len(owned_ids)} games (parallel)")
products_cache = self.api.get_products(owned_ids, progress=progress)
logger.debug(f"_fetch_library_data: got {len(products_cache)} products, building tree data")
owned_set = {str(gid) for gid in owned_ids}
dlc_ids: set[str] = set()
# games_data shared for all platforms — platform filtering happens in populate
games_data: list[tuple[str, str, list[tuple[str, str]], set[str]]] = []
# (game_id, title, [(dlc_id, dlc_title)], available_platforms)
products_cache: dict[str, dict] = {}
games_data: list[tuple[str, str, list[tuple[str, str]], set[str]]] = []
for gid in owned_ids:
product = self.api.get_product_info(gid)
key = str(gid)
product = products_cache.get(key)
if not product:
games_data.append((str(gid), f"Game {gid}", [], set()))
games_data.append((key, f"Game {gid}", [], set()))
continue
products_cache[str(gid)] = product
title = product.get("title", f"Game {gid}")
dlcs: list[tuple[str, str]] = []
for dlc in product.get("expanded_dlcs", []):
@@ -117,15 +135,33 @@ class LibraryTab(QWidget):
dlc_ids.add(dlc_id)
dlcs.append((dlc_id, dlc.get("title", f"DLC {dlc_id}")))
# Detect which platforms have installers
installers = product.get("downloads", {}).get("installers", [])
platforms: set[str] = {inst.get("os", "") for inst in installers}
games_data.append((key, title, dlcs, platforms))
games_data.append((str(gid), title, dlcs, platforms))
# Filter out DLC top-level entries and sort
games_data = [(gid, title, dlcs, plats) for gid, title, dlcs, plats in games_data if gid not in dlc_ids]
games_data.sort(key=lambda x: x[1].lower())
return games_data, products_cache, owned_set
def _on_library_progress(self, done: int, total: int) -> None:
self.status_label.setText(f"Loading library {done}/{total}...")
def _on_library_failed(self, message: str) -> None:
logger.error(f"Library refresh failed: {message}")
self.status_label.setText("Failed to load library.")
def _on_library_fetched(self, result: object) -> None:
"""Populate trees on the main thread from fetched data."""
logger.debug("_on_library_fetched: populating trees (main thread)")
if result is None:
logger.warning("No owned games found or not logged in")
self.status_label.setText("No games found or not logged in.")
return
games_data, products_cache, owned_set = cast(
tuple[list[tuple[str, str, list[tuple[str, str]], set[str]]], dict[str, dict], set[str]],
result,
)
self._updating = True
self.tree_windows.setSortingEnabled(False)
+19 -5
View File
@@ -18,6 +18,7 @@ from loguru import logger
from src.api import GogApi
from src.config import AppConfig, MetadataStore
from src.models import sanitize_folder_name
class SettingsTab(QWidget):
@@ -135,10 +136,13 @@ class SettingsTab(QWidget):
self.status_label.setText("Failed to fetch game library.")
return
title_to_id: dict[str, str] = {game.title: game.game_id for game in owned_games}
# Folders on disk use sanitized titles, so match against sanitized keys.
title_to_id: dict[str, str] = {
sanitize_folder_name(game.title): game.game_id for game in owned_games
}
logger.info(f"Scan: loaded {len(title_to_id)} owned games for matching")
total_detected = 0
all_detected_ids: list[str] = []
scanned_paths: list[str] = []
for path in [self.config.windows_path, self.config.linux_path]:
@@ -147,13 +151,22 @@ class SettingsTab(QWidget):
scanned_paths.append(path)
self.status_label.setText(f"Scanning {path}...")
metadata = MetadataStore(path)
detected = metadata.scan_existing_installers(title_to_id)
total_detected += detected
detected_ids = metadata.scan_existing_installers(title_to_id)
all_detected_ids.extend(detected_ids)
if not scanned_paths:
self.status_label.setText("No paths configured. Set Windows/Linux paths first.")
return
newly_managed = 0
for game_id in all_detected_ids:
if not self.config.is_game_managed(game_id):
self.config.set_game_managed(game_id, True)
newly_managed += 1
if newly_managed:
self.config.save()
total_detected = len(all_detected_ids)
msg = f"Scan complete. Detected {total_detected} game(s) with existing installers."
self.status_label.setText(msg)
logger.info(msg)
@@ -163,5 +176,6 @@ class SettingsTab(QWidget):
self,
"Scan Complete",
f"Found {total_detected} game(s) with existing installers.\n\n"
"Their metadata has been recorded. Check the Status tab for details.",
"Their metadata has been recorded and they have been marked as managed.\n"
"Check the Library and Status tabs for details.",
)
+93 -108
View File
@@ -1,6 +1,8 @@
"""Status tab — installer status overview, update checks, downloads, pruning."""
from collections.abc import Callable
from datetime import datetime, timezone
from typing import cast
from PySide6.QtCore import Qt
from PySide6.QtGui import QBrush, QColor
@@ -22,10 +24,9 @@ from loguru import logger
from src.api import GogApi
from src.config import AppConfig, MetadataStore
from src.downloader import InstallerDownloader
from src.ui.dialog_game_versions import GameVersionsDialog
from src.ui.workers import DownloadWorker, FetchWorker, run_on_thread
from src.models import (
BonusContent,
GameStatus,
GameStatusInfo,
InstallerPlatform,
@@ -72,6 +73,10 @@ class StatusTab(QWidget):
InstallerPlatform.WINDOWS: [],
InstallerPlatform.LINUX: [],
}
self._active_threads: list = []
self._download_worker: DownloadWorker | None = None
self._dl_label = ""
self._dl_result: tuple[int, int] = (0, 0)
self._setup_ui()
def _setup_ui(self) -> None:
@@ -89,6 +94,11 @@ class StatusTab(QWidget):
self.download_btn.setEnabled(False)
controls.addWidget(self.download_btn)
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.clicked.connect(self._cancel_download)
self.cancel_btn.setVisible(False)
controls.addWidget(self.cancel_btn)
self.show_unknown_cb = QCheckBox("Show unversioned/unknown")
self.show_unknown_cb.setChecked(False)
self.show_unknown_cb.toggled.connect(self._repopulate_current)
@@ -136,27 +146,47 @@ class StatusTab(QWidget):
return self.config.windows_path if platform == InstallerPlatform.WINDOWS else self.config.linux_path
def check_updates(self) -> None:
"""Check update status for all managed games, per language."""
managed_ids = self.config.managed_game_ids
"""Check update status for all managed games (fetch in a background thread)."""
if any(thread.isRunning() for thread, _ in self._active_threads):
return # a check is already in progress
managed_ids = list(self.config.managed_game_ids)
if not managed_ids:
self.status_label.setText("No managed games. Go to Library tab to select games.")
return
self.api.clear_cache()
self.check_btn.setEnabled(False)
self.download_btn.setEnabled(False)
self.status_label.setText("Checking updates...")
self._update_platform_tab_visibility()
self.progress_bar.setVisible(True)
self.progress_bar.setRange(0, 0)
# TODO: Run in a thread to avoid blocking the GUI
def fetch(progress: Callable[[int, int], None]) -> object:
self._verify_metadata()
owned_set = self.api.get_owned_ids_set()
products_cache = self.api.get_products(managed_ids, progress=progress)
return products_cache, owned_set
self._verify_metadata()
worker = FetchWorker(fetch)
worker.result.connect(self._on_check_fetched)
worker.failed.connect(self._on_check_failed)
worker.progress.connect(self._on_check_progress)
run_on_thread(self, worker)
owned_set = self.api.get_owned_ids_set()
products_cache: dict[str, dict] = {}
for game_id in managed_ids:
product = self.api.get_product_info(game_id)
if product:
products_cache[game_id] = product
def _on_check_progress(self, done: int, total: int) -> None:
self.status_label.setText(f"Fetching game info {done}/{total}...")
def _on_check_failed(self, message: str) -> None:
logger.error(f"Update check failed: {message}")
self.progress_bar.setVisible(False)
self.check_btn.setEnabled(True)
self.status_label.setText("Failed to check updates.")
def _on_check_fetched(self, result: object) -> None:
"""Build status and populate trees on the main thread (may show dialogs)."""
self.progress_bar.setVisible(False)
products_cache, owned_set = cast(tuple[dict[str, dict], set[str]], result)
self._run_check(products_cache, owned_set)
def check_updates_from_cache(self, products_cache: dict[str, dict], owned_set: set[str]) -> None:
@@ -440,71 +470,14 @@ class StatusTab(QWidget):
return tree_item
def _run_download_for_platform(self, platform: InstallerPlatform) -> tuple[int, int]:
"""Download all pending installers and bonus content for one platform.
def download_updates(self) -> None:
"""Download updates for the currently visible platform (in a background thread)."""
if self._download_worker is not None:
return # a download is already running
Returns (items_downloaded, bonus_files_downloaded).
"""
platform = self._current_platform()
base_path = self._base_path_for(platform)
if not base_path:
return 0, 0
downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED}
to_download = [item for item in self.status_items[platform] if item.status in downloadable]
for item in self.status_items[platform]:
to_download.extend(dlc for dlc in item.dlcs if dlc.status in downloadable)
# TODO: Run in a thread to avoid blocking the GUI
completed = 0
if to_download:
metadata = MetadataStore(base_path)
downloader = InstallerDownloader(self.api, metadata)
self.progress_bar.setVisible(True)
self.progress_bar.setMaximum(len(to_download))
self.progress_bar.setValue(0)
for item in to_download:
self.status_label.setText(f"Downloading: {item.name}...")
folder_name = item.parent_name if item.parent_name else item.name
effective_langs = self.config.get_effective_languages(item.game_id)
installers = self.api.get_installers(item.game_id, platforms=[platform], languages=effective_langs)
gs = self.config.get_game_settings(item.game_id)
is_english_only = gs.english_only if gs.english_only is not None else self.config.english_only
single_language = is_english_only or len({i.language for i in installers}) == 1
for installer in installers:
success = downloader.download_installer(installer, folder_name, single_language=single_language)
if success:
logger.info(f"Downloaded {installer.filename}")
else:
logger.error(f"Failed to download {installer.filename}")
completed += 1
self.progress_bar.setValue(completed)
bonus_count = 0
if any(item.bonus_available > item.bonus_downloaded for item in self.status_items[platform]):
bonus_items_to_download = self._collect_bonus_downloads(platform, base_path)
if bonus_items_to_download:
self.progress_bar.setVisible(True)
self.progress_bar.setMaximum(len(bonus_items_to_download))
self.progress_bar.setValue(0)
for game_name, bonus in bonus_items_to_download:
self.status_label.setText(f"Downloading bonus: {bonus.name}...")
b_metadata = MetadataStore(base_path)
b_downloader = InstallerDownloader(self.api, b_metadata)
if b_downloader.download_bonus(bonus, game_name):
bonus_count += 1
self.progress_bar.setValue(bonus_count)
self.progress_bar.setVisible(False)
return completed, bonus_count
def download_updates(self) -> None:
"""Download updates for the currently visible platform."""
platform = self._current_platform()
if not self._base_path_for(platform):
self.status_label.setText("No path configured for this platform.")
return
@@ -520,46 +493,58 @@ class StatusTab(QWidget):
self.download_btn.setEnabled(False)
self.check_btn.setEnabled(False)
self.cancel_btn.setVisible(True)
self.cancel_btn.setEnabled(True)
self.progress_bar.setVisible(True)
self.progress_bar.setRange(0, 0) # busy indicator until first bytes arrive
self._dl_result = (0, 0)
self._dl_label = ""
completed, bonus_count = self._run_download_for_platform(platform)
worker = DownloadWorker(
self.api, self.config, platform, list(self.status_items[platform]), base_path
)
self._download_worker = worker
worker.byte_progress.connect(self._on_download_bytes)
worker.file_progress.connect(self._on_download_file)
worker.result.connect(self._on_download_result)
worker.done.connect(self._on_download_done)
run_on_thread(self, worker)
def _cancel_download(self) -> None:
if self._download_worker is not None:
self.cancel_btn.setEnabled(False)
self.status_label.setText("Cancelling after current file...")
self._download_worker.cancel()
def _on_download_file(self, index: int, total: int, name: str) -> None:
self._dl_label = f"Downloading ({index}/{total}): {name}"
self.status_label.setText(self._dl_label)
self.progress_bar.setRange(0, 0) # busy until byte progress for this file
def _on_download_bytes(self, downloaded: int, total: int, speed: float) -> None:
if total > 0:
self.progress_bar.setRange(0, total)
self.progress_bar.setValue(downloaded)
speed_mb = speed / (1024 * 1024)
self.status_label.setText(f"{self._dl_label}{speed_mb:.1f} MB/s")
def _on_download_result(self, completed: int, bonus_count: int) -> None:
self._dl_result = (completed, bonus_count)
def _on_download_done(self) -> None:
cancelled = self._download_worker is not None and self._download_worker.is_cancelled()
self._download_worker = None
self.progress_bar.setVisible(False)
self.progress_bar.setRange(0, 100)
self.cancel_btn.setVisible(False)
self.check_btn.setEnabled(True)
completed, bonus_count = self._dl_result
parts = [f"Downloaded {completed} items"]
if bonus_count:
parts.append(f"{bonus_count} bonus files")
self.status_label.setText(f"{', '.join(parts)}. Run 'Check for Updates' to refresh.")
def _collect_bonus_downloads(
self, platform: InstallerPlatform, base_path: str,
) -> list[tuple[str, BonusContent]]:
"""Collect bonus content not yet downloaded for this platform."""
result: list[tuple[str, BonusContent]] = []
metadata = MetadataStore(base_path)
platform_key = "windows" if platform == InstallerPlatform.WINDOWS else "linux"
for item in self.status_items[platform]:
if item.bonus_available <= item.bonus_downloaded:
continue
record = metadata.get_game(item.game_id)
if not record or not record.installers:
product = self.api.get_product_info(item.game_id)
if not product:
continue
has_platform = any(
inst.get("os") == platform_key
for inst in product.get("downloads", {}).get("installers", [])
)
if not has_platform:
continue
downloaded_names = {b.name for b in record.bonuses} if record else set()
folder_name = item.parent_name if item.parent_name else item.name
for bonus in self.api.get_bonus_content(item.game_id):
if bonus.name not in downloaded_names:
result.append((folder_name, bonus))
return result
prefix = "Cancelled. " if cancelled else ""
self.status_label.setText(f"{prefix}{', '.join(parts)}. Run 'Check for Updates' to refresh.")
def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
"""Open version management dialog for the clicked game."""
+222
View File
@@ -0,0 +1,222 @@
"""Background workers for network/IO-heavy operations.
Qt widgets must only be touched from the main thread, so these workers do the
slow work (API calls, downloads) on a separate QThread and communicate results
back via signals.
"""
from collections.abc import Callable
from PySide6.QtCore import QObject, QThread, Signal
from loguru import logger
from src.api import GogApi
from src.config import AppConfig, MetadataStore
from src.downloader import InstallerDownloader
from src.models import BonusContent, GameStatus, GameStatusInfo, InstallerPlatform
class _BaseWorker(QObject):
"""Base worker. Subclasses implement run() and emit `done` when finished."""
done = Signal() # always emitted when run() returns (success or failure)
def run(self) -> None: # pragma: no cover - overridden
raise NotImplementedError
def run_on_thread(owner: QObject, worker: _BaseWorker) -> QThread:
"""Move `worker` to a fresh QThread and start it.
Connect the worker's own signals BEFORE calling this. References to BOTH the
thread and the worker are kept on `owner._active_threads` so neither is
garbage-collected mid-run (a GC'd worker would never have its `run` slot
invoked), and they are cleaned up when the thread finishes.
"""
logger.debug(f"run_on_thread: starting {type(worker).__name__}")
thread = QThread()
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.done.connect(thread.quit)
worker.done.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(lambda: logger.debug("run_on_thread: thread finished"))
if not hasattr(owner, "_active_threads"):
owner._active_threads = [] # type: ignore[attr-defined]
threads: list[tuple[QThread, _BaseWorker]] = owner._active_threads # type: ignore[attr-defined]
entry = (thread, worker)
threads.append(entry)
thread.finished.connect(lambda: threads.remove(entry) if entry in threads else None)
thread.start()
return thread
class FetchWorker(_BaseWorker):
"""Runs an arbitrary network-fetch callable off the main thread.
The callable receives a progress callback `progress(done, total)` and returns
any result object, delivered via the `result` signal. UI work (building trees,
showing dialogs) must be done by the slot connected to `result`, on the main
thread.
"""
progress = Signal(int, int) # done, total
result = Signal(object)
failed = Signal(str)
def __init__(self, fn: Callable[[Callable[[int, int], None]], object]) -> None:
super().__init__()
self._fn = fn
def run(self) -> None:
logger.debug("FetchWorker.run: begin")
try:
res = self._fn(self._emit_progress)
except Exception as e:
logger.exception(f"Fetch worker error: {e}")
self.failed.emit(str(e))
self.done.emit()
return
logger.debug("FetchWorker.run: emitting result")
self.result.emit(res)
self.done.emit()
def _emit_progress(self, done: int, total: int) -> None:
self.progress.emit(done, total)
class DownloadWorker(_BaseWorker):
"""Downloads all pending installers and bonus content for one platform.
Implements the DownloadProgressCallback protocol so it can be passed straight
to InstallerDownloader for per-byte progress reporting.
"""
byte_progress = Signal(int, int, float) # downloaded, total, speed (current file)
file_progress = Signal(int, int, str) # current index (1-based), total, name
result = Signal(int, int) # installers_done, bonus_files_done
def __init__(
self,
api: GogApi,
config: AppConfig,
platform: InstallerPlatform,
items: list[GameStatusInfo],
base_path: str,
) -> None:
super().__init__()
self.api = api
self.config = config
self.platform = platform
self.items = items
self.base_path = base_path
self._cancelled = False
def cancel(self) -> None:
"""Request cancellation (safe to call from the main thread)."""
self._cancelled = True
# --- DownloadProgressCallback protocol ---
def on_progress(self, downloaded: int, total: int, speed: float) -> None:
self.byte_progress.emit(downloaded, total, speed)
def on_finished(self, success: bool, message: str) -> None:
pass
def is_cancelled(self) -> bool:
return self._cancelled
# --- worker body ---
def run(self) -> None:
completed = 0
bonus_count = 0
try:
completed, bonus_count = self._run()
except Exception as e: # defensive: never let a worker thread crash silently
logger.error(f"Download worker error: {e}")
finally:
self.result.emit(completed, bonus_count)
self.done.emit()
def _run(self) -> tuple[int, int]:
if not self.base_path:
return 0, 0
downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED}
to_download = [item for item in self.items if item.status in downloadable]
for item in self.items:
to_download.extend(dlc for dlc in item.dlcs if dlc.status in downloadable)
metadata = MetadataStore(self.base_path)
downloader = InstallerDownloader(self.api, metadata)
bonus_list = self._collect_bonus(metadata)
total = len(to_download) + len(bonus_list)
index = 0
completed = 0
for item in to_download:
if self._cancelled:
break
index += 1
self.file_progress.emit(index, total, item.name)
folder_name = item.parent_name if item.parent_name else item.name
effective_langs = self.config.get_effective_languages(item.game_id)
installers = self.api.get_installers(
item.game_id, platforms=[self.platform], languages=effective_langs
)
gs = self.config.get_game_settings(item.game_id)
is_english_only = gs.english_only if gs.english_only is not None else self.config.english_only
single_language = is_english_only or len({i.language for i in installers}) == 1
for installer in installers:
if self._cancelled:
break
ok = downloader.download_installer(
installer, folder_name, single_language=single_language, callback=self
)
if not ok and not self._cancelled:
logger.error(f"Failed to download {installer.filename}")
completed += 1
bonus_count = 0
for game_name, bonus in bonus_list:
if self._cancelled:
break
index += 1
self.file_progress.emit(index, total, bonus.name)
if downloader.download_bonus(bonus, game_name, callback=self):
bonus_count += 1
return completed, bonus_count
def _collect_bonus(self, metadata: MetadataStore) -> list[tuple[str, BonusContent]]:
"""Collect bonus content not yet downloaded for this platform."""
result: list[tuple[str, BonusContent]] = []
platform_key = "windows" if self.platform == InstallerPlatform.WINDOWS else "linux"
for item in self.items:
if item.bonus_available <= item.bonus_downloaded:
continue
record = metadata.get_game(item.game_id)
if not record or not record.installers:
product = self.api.get_product_info(item.game_id)
if not product:
continue
has_platform = any(
inst.get("os") == platform_key
for inst in product.get("downloads", {}).get("installers", [])
)
if not has_platform:
continue
downloaded_names = {b.name for b in record.bonuses} if record else set()
folder_name = item.parent_name if item.parent_name else item.name
for bonus in self.api.get_bonus_content(item.game_id):
if bonus.name not in downloaded_names:
result.append((folder_name, bonus))
return result