Run library, checks and downloads in background threads with parallel fetching
This commit is contained in:
+80
-7
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user