Initial release — PySide6 app for automatic GOG offline installer management

This commit is contained in:
2026-04-09 13:15:26 +02:00
parent bbedaf7eb4
commit f22dc64041
27 changed files with 4324 additions and 1 deletions

0
src/__init__.py Normal file
View File

2
src/_version.py Normal file
View File

@@ -0,0 +1,2 @@
"""Auto-generated — do not edit manually."""
__version__ = "0.1.0"

250
src/api.py Normal file
View File

@@ -0,0 +1,250 @@
"""GOG API client for fetching game library and installer info."""
from urllib.parse import unquote, urlparse
import requests
from loguru import logger
from src.auth import AuthManager
from src.models import BonusContent, InstallerInfo, InstallerPlatform, InstallerType, OwnedGame
GOG_API = "https://api.gog.com"
GOG_EMBED = "https://embed.gog.com"
class GogApi:
"""Handles communication with the GOG API."""
def __init__(self, auth: AuthManager) -> None:
self.auth = auth
self.session = requests.Session()
self.session.headers.update({"User-Agent": "GOGUpdater/0.1"})
def _ensure_auth(self) -> bool:
token = self.auth.access_token
if not token:
logger.error("Not authenticated")
return False
self.session.headers["Authorization"] = f"Bearer {token}"
return True
def get_owned_game_ids(self) -> list[int]:
"""Return list of owned game IDs."""
if not self._ensure_auth():
return []
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")
return []
if not response.ok:
logger.error(f"Failed to fetch owned games — HTTP {response.status_code}")
return []
return response.json().get("owned", [])
def get_game_details(self, game_id: int | str) -> dict | None:
"""Fetch game details including download links."""
if not self._ensure_auth():
return None
try:
response = self.session.get(
f"{GOG_EMBED}/account/gameDetails/{game_id}.json",
timeout=15,
)
except (requests.ConnectionError, requests.Timeout):
logger.error(f"Failed to fetch game details for {game_id} — network error")
return None
if not response.ok:
logger.error(f"Failed to fetch game details for {game_id} — HTTP {response.status_code}")
return None
return response.json()
def get_product_info(self, game_id: int | str) -> dict | None:
"""Fetch product info with downloads and DLC expansions."""
if not self._ensure_auth():
return None
try:
response = self.session.get(
f"{GOG_API}/products/{game_id}",
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")
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()
def get_owned_games(self) -> list[OwnedGame]:
"""Fetch list of owned games with titles."""
game_ids = self.get_owned_game_ids()
if not game_ids:
return []
games: list[OwnedGame] = []
for gid in game_ids:
info = self.get_product_info(gid)
if info:
games.append(OwnedGame(game_id=str(gid), title=info.get("title", f"Game {gid}")))
else:
games.append(OwnedGame(game_id=str(gid), title=f"Game {gid}"))
return games
def get_installers(
self,
game_id: int | str,
platforms: list[InstallerPlatform] | None = None,
languages: list[str] | None = None,
) -> list[InstallerInfo]:
"""Fetch available installers for a game, filtered by platform and language.
Uses the /products/{id}?expand=downloads,expanded_dlcs endpoint.
Response format: downloads.installers is a flat list of dicts with
keys: id, name, os, language, version, total_size, files[].
"""
product = self.get_product_info(game_id)
if not product:
return []
if platforms is None:
platforms = [InstallerPlatform.WINDOWS, InstallerPlatform.LINUX]
platform_keys = {p: p.value for p in platforms} # WINDOWS -> "windows", LINUX -> "linux"
result: list[InstallerInfo] = []
# Main game installers
installers = product.get("downloads", {}).get("installers", [])
result.extend(self._parse_installers(installers, str(game_id), platform_keys, languages, InstallerType.GAME))
# DLC installers (only for owned DLCs)
owned_ids = self.get_owned_ids_set()
for dlc in product.get("expanded_dlcs", []):
dlc_id = str(dlc.get("id", ""))
if dlc_id not in owned_ids:
continue
dlc_installers = dlc.get("downloads", {}).get("installers", [])
result.extend(self._parse_installers(dlc_installers, dlc_id, platform_keys, languages, InstallerType.DLC))
return result
def _parse_installers(
self,
installers: list[dict],
game_id: str,
platform_keys: dict[InstallerPlatform, str],
languages: list[str] | None,
installer_type: InstallerType,
) -> list[InstallerInfo]:
"""Parse installer entries from products API response.
Each installer dict has: id, name, os, language, version, total_size, files[{id, size, downlink}].
"""
result: list[InstallerInfo] = []
allowed_os = set(platform_keys.values())
for installer in installers:
os_name = installer.get("os", "")
if os_name not in allowed_os:
continue
lang = installer.get("language", "en")
if languages and lang not in languages:
continue
platform = InstallerPlatform.WINDOWS if os_name == "windows" else InstallerPlatform.LINUX
version = installer.get("version") or ""
name = installer.get("name", "unknown")
for file_entry in installer.get("files", []):
downlink = file_entry.get("downlink", "")
file_id = file_entry.get("id", "")
size = file_entry.get("size", 0)
result.append(
InstallerInfo(
installer_id=f"{game_id}_{os_name}_{lang}_{file_id}",
filename=f"{name}_{file_id}",
size=size,
version=version,
language=lang,
platform=platform,
installer_type=installer_type,
download_url=downlink,
game_id=game_id,
)
)
return result
def get_bonus_content(self, game_id: int | str, product: dict | None = None) -> list[BonusContent]:
"""Fetch bonus content (soundtracks, wallpapers, manuals, etc.) for a game."""
if product is None:
product = self.get_product_info(game_id)
if not product:
return []
bonus_items = product.get("downloads", {}).get("bonus_content", [])
result: list[BonusContent] = []
for item in bonus_items:
name = item.get("name", "unknown")
bonus_type = item.get("type", "")
total_size = item.get("total_size", 0)
for file_entry in item.get("files", []):
downlink = file_entry.get("downlink", "")
file_id = str(file_entry.get("id", ""))
size = file_entry.get("size", total_size)
result.append(BonusContent(
bonus_id=f"{game_id}_bonus_{file_id}",
name=name,
bonus_type=bonus_type,
size=size,
download_url=downlink,
game_id=str(game_id),
))
logger.debug(f"Found {len(result)} bonus item(s) for game {game_id}")
return result
def get_owned_ids_set(self) -> set[str]:
"""Return a set of owned game/DLC IDs as strings."""
return {str(gid) for gid in self.get_owned_game_ids()}
def resolve_download_url(self, downlink: str) -> tuple[str, str] | None:
"""Resolve a GOG downlink to actual download URL and real filename.
GOG downlinks are two-level: first returns JSON with 'downlink' key,
second is the actual CDN URL with the real filename in the path.
Returns (url, filename) or None on failure.
"""
if not self._ensure_auth():
return None
# Step 1: Get CDN URL from API downlink
try:
response = self.session.get(downlink, timeout=15)
except (requests.ConnectionError, requests.Timeout):
logger.error("Failed to resolve download URL — network error")
return None
if not response.ok:
logger.error(f"Failed to resolve download URL — HTTP {response.status_code}")
return None
data = response.json()
cdn_url = data.get("downlink", "")
if not cdn_url:
logger.error("No downlink in API response")
return None
# Extract filename from CDN URL path
path = urlparse(cdn_url).path
filename = unquote(path.rsplit("/", 1)[-1])
return cdn_url, filename

129
src/auth.py Normal file
View File

@@ -0,0 +1,129 @@
"""GOG OAuth2 authentication manager."""
import json
import time
from pathlib import Path
import requests
from loguru import logger
GOG_AUTH_URL = "https://auth.gog.com"
CLIENT_ID = "46899977096215655"
CLIENT_SECRET = "9d85c43b1482497dbbce61f6e4aa173a433796eeae2ca8c5f6129f2dc4de46d9"
REDIRECT_URI = "https://embed.gog.com/on_login_success?origin=client"
LOGIN_URL = (
f"https://login.gog.com/auth?client_id={CLIENT_ID}"
f"&redirect_uri={REDIRECT_URI}"
f"&response_type=code&layout=popup"
)
class AuthManager:
"""Manages GOG OAuth2 tokens — exchange, refresh, persistence."""
def __init__(self, config_dir: Path) -> None:
self.config_dir = config_dir
self.auth_file = config_dir / "auth.json"
self.credentials: dict = {}
self.session = requests.Session()
self.session.headers.update({"User-Agent": "GOGUpdater/0.1"})
self._load()
def _load(self) -> None:
if self.auth_file.exists():
try:
self.credentials = json.loads(self.auth_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
logger.warning("Failed to read auth.json, starting fresh")
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")
@property
def is_logged_in(self) -> bool:
return bool(self.credentials.get("access_token"))
@property
def is_expired(self) -> bool:
if not self.is_logged_in:
return True
login_time = self.credentials.get("login_time", 0)
expires_in = self.credentials.get("expires_in", 0)
return time.time() >= login_time + expires_in
@property
def access_token(self) -> str | None:
if not self.is_logged_in:
return None
if self.is_expired:
if not self.refresh():
return None
return self.credentials.get("access_token")
def exchange_code(self, code: str) -> bool:
"""Exchange authorization code for tokens."""
url = (
f"{GOG_AUTH_URL}/token"
f"?client_id={CLIENT_ID}"
f"&client_secret={CLIENT_SECRET}"
f"&grant_type=authorization_code"
f"&redirect_uri={REDIRECT_URI}"
f"&code={code}"
)
try:
response = self.session.get(url, timeout=15)
except (requests.ConnectionError, requests.Timeout):
logger.error("Failed to exchange authorization code — network error")
return False
if not response.ok:
logger.error(f"Failed to exchange authorization code — HTTP {response.status_code}")
return False
data = response.json()
data["login_time"] = time.time()
self.credentials = data
self._save()
logger.info("Successfully authenticated with GOG")
return True
def refresh(self) -> bool:
"""Refresh access token using refresh_token."""
refresh_token = self.credentials.get("refresh_token")
if not refresh_token:
logger.error("No refresh token available")
return False
url = (
f"{GOG_AUTH_URL}/token"
f"?client_id={CLIENT_ID}"
f"&client_secret={CLIENT_SECRET}"
f"&grant_type=refresh_token"
f"&refresh_token={refresh_token}"
)
try:
response = self.session.get(url, timeout=15)
except (requests.ConnectionError, requests.Timeout):
logger.error("Failed to refresh token — network error")
return False
if not response.ok:
logger.error(f"Failed to refresh token — HTTP {response.status_code}")
return False
data = response.json()
data["login_time"] = time.time()
self.credentials = data
self._save()
logger.info("Token refreshed successfully")
return True
def logout(self) -> None:
"""Clear stored credentials."""
self.credentials = {}
if self.auth_file.exists():
self.auth_file.unlink()
logger.info("Logged out")

340
src/config.py Normal file
View File

@@ -0,0 +1,340 @@
"""Application configuration and installer metadata management."""
import json
from pathlib import Path
from loguru import logger
from src.models import DownloadedInstaller, GameRecord, GameSettings, InstallerType, LANGUAGE_NAMES, language_folder_name
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "gogupdater"
METADATA_FILENAME = "gogupdater.json"
class AppConfig:
"""Application settings stored in ~/.config/gogupdater/config.json."""
def __init__(self, config_dir: Path = DEFAULT_CONFIG_DIR) -> None:
self.config_dir = config_dir
self.config_file = config_dir / "config.json"
self.windows_path: str = ""
self.linux_path: str = ""
self.managed_game_ids: list[str] = []
self.languages: list[str] = ["en"]
self.english_only: bool = False
self.include_bonus: bool = False
self.game_settings: dict[str, GameSettings] = {}
self._load()
def _load(self) -> None:
if not self.config_file.exists():
return
try:
data = json.loads(self.config_file.read_text(encoding="utf-8"))
self.windows_path = data.get("windows_path", "")
self.linux_path = data.get("linux_path", "")
self.managed_game_ids = data.get("managed_game_ids", [])
self.languages = data.get("languages", ["en"])
self.english_only = data.get("english_only", False)
self.include_bonus = data.get("include_bonus", False)
self.game_settings = {
gid: GameSettings.from_dict(gs)
for gid, gs in data.get("game_settings", {}).items()
}
except (json.JSONDecodeError, OSError):
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,
"managed_game_ids": self.managed_game_ids,
"languages": self.languages,
"english_only": self.english_only,
"include_bonus": self.include_bonus,
"game_settings": {
gid: gs.to_dict()
for gid, gs in self.game_settings.items()
if not gs.is_default()
},
}
self.config_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
def is_game_managed(self, game_id: str) -> bool:
return game_id in self.managed_game_ids
def get_effective_languages(self, game_id: str | None = None) -> list[str]:
"""Return effective language list, checking per-game overrides first."""
english_only = self.english_only
languages = self.languages
if game_id and game_id in self.game_settings:
gs = self.game_settings[game_id]
if gs.english_only is not None:
english_only = gs.english_only
if gs.languages is not None:
languages = gs.languages
if english_only:
return ["en"]
return languages
def get_effective_include_bonus(self, game_id: str | None = None) -> bool:
"""Return include_bonus setting, checking per-game overrides first."""
if game_id and game_id in self.game_settings:
gs = self.game_settings[game_id]
if gs.include_bonus is not None:
return gs.include_bonus
return self.include_bonus
def get_game_settings(self, game_id: str) -> GameSettings:
"""Return per-game settings (may be all defaults)."""
return self.game_settings.get(game_id, GameSettings())
def set_game_settings(self, game_id: str, settings: GameSettings) -> None:
"""Save per-game settings. Removes entry if all defaults."""
if settings.is_default():
self.game_settings.pop(game_id, None)
else:
self.game_settings[game_id] = settings
self.save()
def set_game_managed(self, game_id: str, managed: bool) -> None:
if managed and game_id not in self.managed_game_ids:
self.managed_game_ids.append(game_id)
elif not managed and game_id in self.managed_game_ids:
self.managed_game_ids.remove(game_id)
self.save()
class MetadataStore:
"""Manages gogupdater.json metadata file in installer directories."""
def __init__(self, base_path: str) -> None:
self.base_path = Path(base_path)
self.metadata_file = self.base_path / METADATA_FILENAME
self.games: dict[str, GameRecord] = {}
self._load()
def _load(self) -> None:
if not self.metadata_file.exists():
return
try:
data = json.loads(self.metadata_file.read_text(encoding="utf-8"))
for game_id, game_data in data.get("games", {}).items():
self.games[game_id] = GameRecord.from_dict(game_id, game_data)
except (json.JSONDecodeError, OSError):
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")
def get_game(self, game_id: str) -> GameRecord | None:
return self.games.get(game_id)
def update_game(self, record: GameRecord) -> None:
self.games[record.game_id] = record
self.save()
def get_downloaded_versions(self, game_id: str) -> list[str]:
"""Return list of downloaded versions for a game."""
record = self.games.get(game_id)
if not record:
return []
return list({i.version for i in record.installers})
def prune_old_versions(self, game_id: str, keep_latest: int = 1) -> list[Path]:
"""Remove old version directories, keeping the N most recent. Returns deleted paths."""
record = self.games.get(game_id)
if not record:
return []
versions = sorted({i.version for i in record.installers})
if len(versions) <= keep_latest:
return []
versions_to_remove = versions[:-keep_latest]
deleted: list[Path] = []
for version in versions_to_remove:
version_dir = self.base_path / record.name / version
if version_dir.exists():
import shutil
shutil.rmtree(version_dir)
deleted.append(version_dir)
logger.info(f"Pruned {version_dir}")
record.installers = [i for i in record.installers if i.version not in versions_to_remove]
self.save()
return deleted
def verify_metadata(self) -> int:
"""Verify that recorded files still exist on disk.
Removes metadata entries for missing files. If a game has no
remaining installers or bonuses, removes the entire game record.
Returns number of stale entries removed.
"""
if not self.base_path.is_dir():
return 0
removed = 0
game_ids_to_delete: list[str] = []
for game_id, record in self.games.items():
game_dir = self.base_path / record.name
# If the entire game folder is gone, remove the record
if not game_dir.exists():
game_ids_to_delete.append(game_id)
removed += len(record.installers) + len(record.bonuses)
logger.info(f"Verify: game folder missing for '{record.name}', removing record")
continue
# Check individual installer files
valid_installers: list[DownloadedInstaller] = []
for inst in record.installers:
# Try with language subfolder first, then without
lang_path = game_dir / inst.version / language_folder_name(inst.language) / inst.filename
direct_path = game_dir / inst.version / inst.filename
if lang_path.exists() or direct_path.exists():
valid_installers.append(inst)
else:
removed += 1
logger.info(f"Verify: missing installer '{inst.filename}' for '{record.name}'")
# Check bonus files
valid_bonuses = []
for bonus in record.bonuses:
bonus_path = game_dir / "Bonus" / bonus.filename
if bonus_path.exists():
valid_bonuses.append(bonus)
else:
removed += 1
logger.info(f"Verify: missing bonus '{bonus.filename}' for '{record.name}'")
record.installers = valid_installers
record.bonuses = valid_bonuses
# Update latest_version based on remaining installers
if valid_installers:
versions = sorted({i.version for i in valid_installers})
record.latest_version = versions[-1]
else:
record.latest_version = ""
# If nothing remains, mark for deletion
if not valid_installers and not valid_bonuses:
game_ids_to_delete.append(game_id)
for game_id in game_ids_to_delete:
del self.games[game_id]
if removed:
self.save()
logger.info(f"Verify: removed {removed} stale metadata entries")
return removed
def scan_existing_installers(self, title_to_id: dict[str, str]) -> int:
"""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.
"""
if not self.base_path.is_dir():
logger.warning(f"Scan path does not exist: {self.base_path}")
return 0
# Reverse language map: folder name -> code
lang_name_to_code: dict[str, str] = {v: k for k, v in LANGUAGE_NAMES.items()}
detected = 0
for game_dir in sorted(self.base_path.iterdir()):
if not game_dir.is_dir() or game_dir.name.startswith("."):
continue
if game_dir.name == METADATA_FILENAME:
continue
game_name = game_dir.name
game_id = title_to_id.get(game_name)
if not game_id:
logger.debug(f"Scan: no matching game ID for folder '{game_name}', skipping")
continue
# Already tracked — skip
if game_id in self.games:
logger.debug(f"Scan: '{game_name}' already in metadata, skipping")
continue
installers: list[DownloadedInstaller] = []
latest_version = ""
for version_dir in sorted(game_dir.iterdir()):
if not version_dir.is_dir():
continue
version = version_dir.name
if not latest_version:
latest_version = version
# Check if subdirs are language folders or direct installer files
sub_entries = list(version_dir.iterdir())
has_lang_subdirs = any(
e.is_dir() and e.name in lang_name_to_code for e in sub_entries
)
if has_lang_subdirs:
for lang_dir in version_dir.iterdir():
if not lang_dir.is_dir():
continue
lang_code = lang_name_to_code.get(lang_dir.name, lang_dir.name)
for f in lang_dir.iterdir():
if f.is_file() and not f.name.startswith("."):
installers.append(DownloadedInstaller(
filename=f.name,
size=f.stat().st_size,
version=version,
language=lang_code,
installer_type=InstallerType.GAME,
downloaded_at="",
))
else:
# No language subfolder — files directly in version dir
for f in version_dir.iterdir():
if f.is_file() and not f.name.startswith("."):
installers.append(DownloadedInstaller(
filename=f.name,
size=f.stat().st_size,
version=version,
language="en",
installer_type=InstallerType.GAME,
downloaded_at="",
))
if installers:
# Latest version = last sorted version directory
all_versions = sorted({i.version for i in installers})
latest_version = all_versions[-1] if all_versions else ""
record = GameRecord(
game_id=game_id,
name=game_name,
latest_version=latest_version,
managed=True,
installers=installers,
)
self.games[game_id] = record
detected += 1
logger.info(f"Scan: detected '{game_name}' with {len(installers)} installer(s), version {latest_version}")
if detected:
self.save()
return detected

80
src/constants.py Normal file
View File

@@ -0,0 +1,80 @@
"""
Generic application constants template.
Usage in your project:
1. Copy this file to src/constants.py
2. Fill in APP_NAME and APP_FULL_NAME
3. Import VERSION, APP_TITLE, DEFAULT_DEBUG where needed
Version loading priority:
1. pyproject.toml [project] version (preferred)
2. src/_version.py __version__ (generated fallback for frozen builds)
3. "0.0.0" (last resort)
Debug mode:
Controlled exclusively via .env: ENV_DEBUG=true
Accepted true-values: true, 1, yes (case-insensitive)
"""
import os
import tomllib
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# ---------------------------------------------------------------------------
# Version
# ---------------------------------------------------------------------------
_ROOT = Path(__file__).parent.parent
_PYPROJECT = _ROOT / "pyproject.toml"
_VERSION_FILE = Path(__file__).parent / "_version.py"
def _load_version() -> str:
# 1. pyproject.toml
try:
with open(_PYPROJECT, "rb") as f:
version = tomllib.load(f)["project"]["version"]
# Write fallback for frozen/PyInstaller builds
_VERSION_FILE.write_text(
f'"""Auto-generated — do not edit manually."""\n__version__ = "{version}"\n',
encoding="utf-8",
)
return version
except (FileNotFoundError, KeyError):
pass
# 2. _version.py
try:
from src._version import __version__ # type: ignore[import]
return __version__
except ImportError:
pass
# 3. last resort
return "0.0.0"
# ---------------------------------------------------------------------------
# Debug mode
# ---------------------------------------------------------------------------
def _load_debug() -> bool:
return os.getenv("ENV_DEBUG", "false").lower() in ("true", "1", "yes")
# ---------------------------------------------------------------------------
# Public constants ← fill in APP_NAME / APP_FULL_NAME for each project
# ---------------------------------------------------------------------------
APP_NAME: str = "GOGUpdater"
APP_FULL_NAME: str = "GOG Updater"
_VERSION_NUMBER: str = _load_version()
DEFAULT_DEBUG: bool = _load_debug()
VERSION: str = f"v{_VERSION_NUMBER}" + ("DEV" if DEFAULT_DEBUG else "")
APP_TITLE: str = f"{APP_FULL_NAME} {VERSION}"

284
src/downloader.py Normal file
View File

@@ -0,0 +1,284 @@
"""Installer file downloader with progress reporting and resume support."""
import time
from datetime import datetime, timezone
from typing import Protocol
import requests
from loguru import logger
from src.api import GogApi
from src.config import MetadataStore
from src.models import (
BonusContent,
DownloadedBonus,
DownloadedInstaller,
GameRecord,
InstallerInfo,
language_folder_name,
sanitize_folder_name,
)
class DownloadProgressCallback(Protocol):
"""Protocol for progress reporting during downloads."""
def on_progress(self, downloaded: int, total: int, speed: float) -> None: ...
def on_finished(self, success: bool, message: str) -> None: ...
def is_cancelled(self) -> bool: ...
class InstallerDownloader:
"""Downloads installer files from GOG with resume support."""
CHUNK_SIZE = 1024 * 1024 # 1 MiB
def __init__(self, api: GogApi, metadata: MetadataStore) -> None:
self.api = api
self.metadata = metadata
def download_installer(
self,
installer: InstallerInfo,
game_name: str,
single_language: bool = False,
callback: DownloadProgressCallback | None = None,
) -> bool:
"""Download a single installer file to the correct directory structure.
If single_language is True, the language subfolder is skipped
(game only available in one language).
"""
game_name = sanitize_folder_name(game_name)
# Resolve actual download URL and real filename
resolved = self.api.resolve_download_url(installer.download_url)
if not resolved:
logger.error(f"Could not resolve download URL for {installer.filename}")
if callback:
callback.on_finished(False, "Failed to resolve download URL")
return False
actual_url, real_filename = resolved
version = installer.version if installer.version else "unknown"
if single_language:
target_dir = self.metadata.base_path / game_name / version
else:
lang_folder = language_folder_name(installer.language)
target_dir = self.metadata.base_path / game_name / version / lang_folder
target_dir.mkdir(parents=True, exist_ok=True)
target_file = target_dir / real_filename
# Check for partial download (resume)
existing_size = target_file.stat().st_size if target_file.exists() else 0
headers: dict[str, str] = {}
if existing_size > 0:
headers["Range"] = f"bytes={existing_size}-"
logger.info(f"Resuming download of {real_filename} from {existing_size} bytes")
try:
response = requests.get(actual_url, headers=headers, stream=True, timeout=30)
except (requests.ConnectionError, requests.Timeout) as e:
logger.error(f"Download failed — {e}")
if callback:
callback.on_finished(False, f"Network error: {e}")
return False
if response.status_code == 416:
logger.info(f"Already fully downloaded: {real_filename}")
self._record_download(installer, game_name, real_filename)
if callback:
callback.on_finished(True, "Already downloaded")
return True
if not response.ok:
logger.error(f"Download failed — HTTP {response.status_code}")
if callback:
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"
last_time = time.monotonic()
last_downloaded = downloaded
try:
with open(target_file, mode) as f:
for chunk in response.iter_content(chunk_size=self.CHUNK_SIZE):
if callback and callback.is_cancelled():
logger.info("Download cancelled by user")
callback.on_finished(False, "Cancelled")
return False
f.write(chunk)
downloaded += len(chunk)
if callback:
now = time.monotonic()
elapsed = now - last_time
if elapsed >= 0.5:
speed = (downloaded - last_downloaded) / elapsed
last_time = now
last_downloaded = downloaded
callback.on_progress(downloaded, total_size, speed)
except OSError as e:
logger.error(f"Write error: {e}")
if callback:
callback.on_finished(False, f"Write error: {e}")
return False
self._record_download(installer, game_name, real_filename)
logger.info(f"Downloaded {real_filename} ({downloaded} bytes)")
if callback:
callback.on_finished(True, "Download complete")
return True
def download_bonus(
self,
bonus: BonusContent,
game_name: str,
callback: DownloadProgressCallback | None = None,
) -> bool:
"""Download a bonus content file to GameName/Bonus/ directory."""
game_name = sanitize_folder_name(game_name)
resolved = self.api.resolve_download_url(bonus.download_url)
if not resolved:
logger.error(f"Could not resolve download URL for bonus '{bonus.name}'")
if callback:
callback.on_finished(False, "Failed to resolve download URL")
return False
actual_url, real_filename = resolved
target_dir = self.metadata.base_path / game_name / "Bonus"
target_dir.mkdir(parents=True, exist_ok=True)
target_file = target_dir / real_filename
# Skip if already downloaded with same size
if target_file.exists() and target_file.stat().st_size == bonus.size:
logger.info(f"Bonus already downloaded: {real_filename}")
self._record_bonus(bonus, game_name, real_filename)
if callback:
callback.on_finished(True, "Already downloaded")
return True
existing_size = target_file.stat().st_size if target_file.exists() else 0
headers: dict[str, str] = {}
if existing_size > 0:
headers["Range"] = f"bytes={existing_size}-"
logger.info(f"Resuming bonus download of {real_filename} from {existing_size} bytes")
try:
response = requests.get(actual_url, headers=headers, stream=True, timeout=30)
except (requests.ConnectionError, requests.Timeout) as e:
logger.error(f"Bonus download failed — {e}")
if callback:
callback.on_finished(False, f"Network error: {e}")
return False
if response.status_code == 416:
logger.info(f"Bonus already fully downloaded: {real_filename}")
self._record_bonus(bonus, game_name, real_filename)
if callback:
callback.on_finished(True, "Already downloaded")
return True
if not response.ok:
logger.error(f"Bonus download failed — HTTP {response.status_code}")
if callback:
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"
last_time = time.monotonic()
last_downloaded = downloaded
try:
with open(target_file, mode) as f:
for chunk in response.iter_content(chunk_size=self.CHUNK_SIZE):
if callback and callback.is_cancelled():
logger.info("Bonus download cancelled by user")
callback.on_finished(False, "Cancelled")
return False
f.write(chunk)
downloaded += len(chunk)
if callback:
now = time.monotonic()
elapsed = now - last_time
if elapsed >= 0.5:
speed = (downloaded - last_downloaded) / elapsed
last_time = now
last_downloaded = downloaded
callback.on_progress(downloaded, total_size, speed)
except OSError as e:
logger.error(f"Bonus write error: {e}")
if callback:
callback.on_finished(False, f"Write error: {e}")
return False
self._record_bonus(bonus, game_name, real_filename)
logger.info(f"Downloaded bonus {real_filename} ({downloaded} bytes)")
if callback:
callback.on_finished(True, "Download complete")
return True
def _record_bonus(self, bonus: BonusContent, game_name: str, real_filename: str) -> None:
"""Record the bonus download in metadata store."""
game_name = sanitize_folder_name(game_name)
record = self.metadata.get_game(bonus.game_id)
if not record:
record = GameRecord(
game_id=bonus.game_id,
name=game_name,
)
downloaded = DownloadedBonus(
filename=real_filename,
name=bonus.name,
bonus_type=bonus.bonus_type,
size=bonus.size,
downloaded_at=datetime.now(timezone.utc).isoformat(),
)
record.bonuses = [b for b in record.bonuses if b.filename != real_filename]
record.bonuses.append(downloaded)
record.last_checked = datetime.now(timezone.utc).isoformat()
self.metadata.update_game(record)
def _record_download(self, installer: InstallerInfo, game_name: str, real_filename: str) -> None:
"""Record the download in metadata store."""
game_name = sanitize_folder_name(game_name)
record = self.metadata.get_game(installer.game_id)
if not record:
record = GameRecord(
game_id=installer.game_id,
name=game_name,
latest_version=installer.version,
)
downloaded = DownloadedInstaller(
filename=real_filename,
size=installer.size,
version=installer.version,
language=installer.language,
installer_type=installer.installer_type,
downloaded_at=datetime.now(timezone.utc).isoformat(),
)
# Replace existing entry for same file, or add new
record.installers = [i for i in record.installers if i.filename != real_filename]
record.installers.append(downloaded)
record.latest_version = installer.version
record.last_checked = datetime.now(timezone.utc).isoformat()
self.metadata.update_game(record)

338
src/models.py Normal file
View File

@@ -0,0 +1,338 @@
"""Data models for GOGUpdater."""
import re
from dataclasses import dataclass, field
from enum import Enum
LANGUAGE_NAMES: dict[str, str] = {
"en": "English",
"fr": "French",
"de": "German",
"es": "Spanish",
"it": "Italian",
"pt-BR": "Portuguese (Brazil)",
"ru": "Russian",
"pl": "Polish",
"nl": "Dutch",
"cs": "Czech",
"sk": "Slovak",
"hu": "Hungarian",
"tr": "Turkish",
"ja": "Japanese",
"ko": "Korean",
"zh-Hans": "Chinese (Simplified)",
"zh-Hant": "Chinese (Traditional)",
"ro": "Romanian",
"da": "Danish",
"fi": "Finnish",
"sv": "Swedish",
"no": "Norwegian",
"ar": "Arabic",
"uk": "Ukrainian",
}
def language_folder_name(code: str) -> str:
"""Return human-readable language name for folder naming."""
return LANGUAGE_NAMES.get(code, code)
# Characters to strip entirely from folder names
_STRIP_CHARS_RE = re.compile(r"[®™©™\u2122\u00AE\u00A9]")
# Characters invalid in folder names on Windows/Linux
_INVALID_CHARS_RE = re.compile(r'[<>"/\\|?*]')
def sanitize_folder_name(name: str) -> str:
"""Convert a game title to a valid, unified folder name.
- Replaces ':' with ' - '
- Strips special characters (®, ™, ©)
- Removes other invalid filesystem characters
- Collapses multiple spaces/dashes
- Strips leading/trailing whitespace and dots
"""
result = name
# Colon -> " - "
result = result.replace(":", " - ")
# Strip special symbols
result = _STRIP_CHARS_RE.sub("", result)
# Remove filesystem-invalid characters
result = _INVALID_CHARS_RE.sub("", result)
# Collapse multiple spaces
result = re.sub(r"\s{2,}", " ", result)
# Collapse " - - " patterns from adjacent replacements
result = re.sub(r"(\s*-\s*){2,}", " - ", result)
# Strip leading/trailing whitespace and dots
result = result.strip().strip(".")
return result
class InstallerType(Enum):
GAME = "game"
DLC = "dlc"
class InstallerPlatform(Enum):
WINDOWS = "windows"
LINUX = "linux"
class GameStatus(Enum):
UP_TO_DATE = "up_to_date"
UPDATE_AVAILABLE = "update_available"
NOT_DOWNLOADED = "not_downloaded"
UNKNOWN = "unknown"
UNVERSIONED = "unversioned"
@dataclass
class InstallerInfo:
"""Installer metadata from GOG API."""
installer_id: str
filename: str
size: int
version: str
language: str
platform: InstallerPlatform
installer_type: InstallerType
download_url: str
game_id: str
def to_dict(self) -> dict:
return {
"installer_id": self.installer_id,
"filename": self.filename,
"size": self.size,
"version": self.version,
"language": self.language,
"platform": self.platform.value,
"installer_type": self.installer_type.value,
"download_url": self.download_url,
"game_id": self.game_id,
}
@classmethod
def from_dict(cls, data: dict) -> "InstallerInfo":
return cls(
installer_id=data["installer_id"],
filename=data["filename"],
size=data["size"],
version=data["version"],
language=data["language"],
platform=InstallerPlatform(data["platform"]),
installer_type=InstallerType(data["installer_type"]),
download_url=data.get("download_url", ""),
game_id=data.get("game_id", ""),
)
@dataclass
class DownloadedInstaller:
"""Record of a downloaded installer file."""
filename: str
size: int
version: str
language: str
installer_type: InstallerType
downloaded_at: str
def to_dict(self) -> dict:
return {
"filename": self.filename,
"size": self.size,
"version": self.version,
"language": self.language,
"installer_type": self.installer_type.value,
"downloaded_at": self.downloaded_at,
}
@classmethod
def from_dict(cls, data: dict) -> "DownloadedInstaller":
return cls(
filename=data["filename"],
size=data["size"],
version=data["version"],
language=data["language"],
installer_type=InstallerType(data.get("installer_type", "game")),
downloaded_at=data.get("downloaded_at", ""),
)
@dataclass
class GameRecord:
"""Record of a managed game in metadata file."""
game_id: str
name: str
latest_version: str = ""
managed: bool = True
installers: list[DownloadedInstaller] = field(default_factory=list)
bonuses: list[DownloadedBonus] = field(default_factory=list)
last_checked: str = ""
def to_dict(self) -> dict:
return {
"name": self.name,
"latest_version": self.latest_version,
"managed": self.managed,
"installers": [i.to_dict() for i in self.installers],
"bonuses": [b.to_dict() for b in self.bonuses],
"last_checked": self.last_checked,
}
@classmethod
def from_dict(cls, game_id: str, data: dict) -> "GameRecord":
return cls(
game_id=game_id,
name=data["name"],
latest_version=data.get("latest_version", ""),
managed=data.get("managed", True),
installers=[DownloadedInstaller.from_dict(i) for i in data.get("installers", [])],
bonuses=[DownloadedBonus.from_dict(b) for b in data.get("bonuses", [])],
last_checked=data.get("last_checked", ""),
)
@dataclass
class OwnedGame:
"""Game from GOG library."""
game_id: str
title: str
managed: bool = False
@dataclass
class BonusContent:
"""Bonus content item from GOG API (soundtrack, wallpaper, manual, etc.)."""
bonus_id: str
name: str
bonus_type: str
size: int
download_url: str
game_id: str
def to_dict(self) -> dict:
return {
"bonus_id": self.bonus_id,
"name": self.name,
"bonus_type": self.bonus_type,
"size": self.size,
"download_url": self.download_url,
"game_id": self.game_id,
}
@classmethod
def from_dict(cls, data: dict) -> "BonusContent":
return cls(
bonus_id=data["bonus_id"],
name=data["name"],
bonus_type=data.get("bonus_type", ""),
size=data.get("size", 0),
download_url=data.get("download_url", ""),
game_id=data.get("game_id", ""),
)
@dataclass
class DownloadedBonus:
"""Record of a downloaded bonus content file."""
filename: str
name: str
bonus_type: str
size: int
downloaded_at: str
def to_dict(self) -> dict:
return {
"filename": self.filename,
"name": self.name,
"bonus_type": self.bonus_type,
"size": self.size,
"downloaded_at": self.downloaded_at,
}
@classmethod
def from_dict(cls, data: dict) -> "DownloadedBonus":
return cls(
filename=data["filename"],
name=data["name"],
bonus_type=data.get("bonus_type", ""),
size=data.get("size", 0),
downloaded_at=data.get("downloaded_at", ""),
)
@dataclass
class GameSettings:
"""Per-game settings that override global defaults. None = use global."""
languages: list[str] | None = None
english_only: bool | None = None
include_bonus: bool | None = None
def to_dict(self) -> dict:
data: dict = {}
if self.languages is not None:
data["languages"] = self.languages
if self.english_only is not None:
data["english_only"] = self.english_only
if self.include_bonus is not None:
data["include_bonus"] = self.include_bonus
return data
@classmethod
def from_dict(cls, data: dict) -> "GameSettings":
return cls(
languages=data.get("languages"),
english_only=data.get("english_only"),
include_bonus=data.get("include_bonus"),
)
def is_default(self) -> bool:
"""True if all values are None (no overrides)."""
return self.languages is None and self.english_only is None and self.include_bonus is None
@dataclass
class LanguageVersionInfo:
"""Version info for a single language of a game."""
language: str
current_version: str
latest_version: str
status: GameStatus
@dataclass
class GameStatusInfo:
"""Status information for display in status tab."""
game_id: str
name: str
current_version: str
latest_version: str
status: GameStatus
language_versions: list[LanguageVersionInfo] = field(default_factory=list)
last_checked: str = ""
parent_name: str = ""
dlcs: list["GameStatusInfo"] = field(default_factory=list)
bonus_available: int = 0
bonus_downloaded: int = 0
@property
def status_text(self) -> str:
labels = {
GameStatus.UP_TO_DATE: "Up to date",
GameStatus.UPDATE_AVAILABLE: "Update available",
GameStatus.NOT_DOWNLOADED: "Not downloaded",
GameStatus.UNKNOWN: "Unknown",
GameStatus.UNVERSIONED: "Cannot track version",
}
return labels[self.status]

0
src/ui/__init__.py Normal file
View File

View File

@@ -0,0 +1,128 @@
"""Per-game settings dialog — override global language, bonus, and english_only settings."""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QCheckBox,
QDialog,
QDialogButtonBox,
QGroupBox,
QLabel,
QListWidget,
QListWidgetItem,
QVBoxLayout,
QWidget,
)
from loguru import logger
from src.config import AppConfig
from src.models import GameSettings, LANGUAGE_NAMES
class GameSettingsDialog(QDialog):
"""Dialog for configuring per-game settings (languages, bonus, english_only)."""
def __init__(
self,
game_id: str,
game_title: str,
config: AppConfig,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.game_id = game_id
self.config = config
self.settings = config.get_game_settings(game_id)
self.setWindowTitle(f"Settings — {game_title}")
self.setMinimumWidth(400)
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
info = QLabel(
"Override global settings for this game.\n"
"Unchecked overrides use global defaults from Settings/Languages tabs."
)
info.setWordWrap(True)
layout.addWidget(info)
# English only override
self.english_only_group = QGroupBox("Override English only")
self.english_only_group.setCheckable(True)
self.english_only_group.setChecked(self.settings.english_only is not None)
eo_layout = QVBoxLayout(self.english_only_group)
self.english_only_cb = QCheckBox("English only")
self.english_only_cb.setChecked(
self.settings.english_only if self.settings.english_only is not None else self.config.english_only
)
eo_layout.addWidget(self.english_only_cb)
layout.addWidget(self.english_only_group)
# Languages override
self.langs_group = QGroupBox("Override languages")
self.langs_group.setCheckable(True)
self.langs_group.setChecked(self.settings.languages is not None)
langs_layout = QVBoxLayout(self.langs_group)
self.lang_list = QListWidget()
effective_langs = self.settings.languages if self.settings.languages is not None else self.config.languages
for code, name in LANGUAGE_NAMES.items():
item = QListWidgetItem(f"{name} ({code})")
item.setData(Qt.ItemDataRole.UserRole, code)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
checked = code in effective_langs
item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
self.lang_list.addItem(item)
langs_layout.addWidget(self.lang_list)
layout.addWidget(self.langs_group)
# Bonus override
self.bonus_group = QGroupBox("Override bonus content")
self.bonus_group.setCheckable(True)
self.bonus_group.setChecked(self.settings.include_bonus is not None)
bonus_layout = QVBoxLayout(self.bonus_group)
self.bonus_cb = QCheckBox("Include bonus content")
self.bonus_cb.setChecked(
self.settings.include_bonus if self.settings.include_bonus is not None else self.config.include_bonus
)
bonus_layout.addWidget(self.bonus_cb)
layout.addWidget(self.bonus_group)
# Buttons
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
buttons.accepted.connect(self._accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _accept(self) -> None:
# English only
english_only: bool | None = None
if self.english_only_group.isChecked():
english_only = self.english_only_cb.isChecked()
# Languages
languages: list[str] | None = None
if self.langs_group.isChecked():
languages = []
for i in range(self.lang_list.count()):
item = self.lang_list.item(i)
if item and item.checkState() == Qt.CheckState.Checked:
code = item.data(Qt.ItemDataRole.UserRole)
languages.append(code)
if not languages:
languages = ["en"]
# Bonus
include_bonus: bool | None = None
if self.bonus_group.isChecked():
include_bonus = self.bonus_cb.isChecked()
new_settings = GameSettings(
languages=languages,
english_only=english_only,
include_bonus=include_bonus,
)
self.config.set_game_settings(self.game_id, new_settings)
logger.info(f"Per-game settings saved for {self.game_id}: {new_settings}")
self.accept()

View File

@@ -0,0 +1,325 @@
"""Dialog showing downloaded versions of a game with management actions."""
import shutil
from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from loguru import logger
from src.config import MetadataStore
from src.models import DownloadedInstaller, language_folder_name, sanitize_folder_name
COL_FILE = 0
COL_VERSION = 1
COL_LANG = 2
COL_SIZE = 3
COL_DATE = 4
def _fmt_size(size: int) -> str:
for unit in ("B", "KB", "MB", "GB"):
if size < 1024:
return f"{size:.1f} {unit}"
size //= 1024
return f"{size:.1f} GB"
def _installer_path(base_path: Path, game_name: str, installer: DownloadedInstaller) -> Path:
"""Reconstruct the path where an installer file lives."""
lang_path = base_path / game_name / installer.version / language_folder_name(installer.language) / installer.filename
direct_path = base_path / game_name / installer.version / installer.filename
return lang_path if lang_path.exists() else direct_path
class GameVersionsDialog(QDialog):
"""Shows all downloaded versions/files for a game and lets user manage them."""
def __init__(
self,
game_id: str,
game_title: str,
metadata: MetadataStore,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.game_id = game_id
self.game_title = game_title
self.metadata = metadata
self.base_path = metadata.base_path
self.game_folder = self.base_path / sanitize_folder_name(game_title)
self.setWindowTitle(f"Downloaded versions — {game_title}")
self.setMinimumSize(700, 450)
self._setup_ui()
self._populate()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
# Header info
self.info_label = QLabel()
layout.addWidget(self.info_label)
# Installers group
inst_group = QGroupBox("Installer files")
inst_layout = QVBoxLayout(inst_group)
self.inst_tree = QTreeWidget()
self.inst_tree.setColumnCount(5)
self.inst_tree.setHeaderLabels(["File", "Version", "Language", "Size", "Downloaded"])
self.inst_tree.header().setStretchLastSection(False)
self.inst_tree.header().resizeSection(COL_FILE, 280)
self.inst_tree.header().resizeSection(COL_VERSION, 100)
self.inst_tree.header().resizeSection(COL_LANG, 100)
self.inst_tree.header().resizeSection(COL_SIZE, 80)
self.inst_tree.header().resizeSection(COL_DATE, 160)
self.inst_tree.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection)
self.inst_tree.setSortingEnabled(True)
inst_layout.addWidget(self.inst_tree)
inst_btn_row = QHBoxLayout()
self.delete_selected_btn = QPushButton("Delete selected files")
self.delete_selected_btn.clicked.connect(self._delete_selected_installers)
inst_btn_row.addWidget(self.delete_selected_btn)
self.delete_version_btn = QPushButton("Delete entire version folder…")
self.delete_version_btn.clicked.connect(self._delete_version_folder)
inst_btn_row.addWidget(self.delete_version_btn)
inst_btn_row.addStretch()
inst_layout.addLayout(inst_btn_row)
layout.addWidget(inst_group)
# Bonus group
bonus_group = QGroupBox("Bonus content")
bonus_layout = QVBoxLayout(bonus_group)
self.bonus_tree = QTreeWidget()
self.bonus_tree.setColumnCount(4)
self.bonus_tree.setHeaderLabels(["File", "Name", "Type", "Size"])
self.bonus_tree.header().setStretchLastSection(False)
self.bonus_tree.header().resizeSection(COL_FILE, 280)
self.bonus_tree.header().resizeSection(1, 180)
self.bonus_tree.header().resizeSection(2, 100)
self.bonus_tree.setSelectionMode(QTreeWidget.SelectionMode.ExtendedSelection)
bonus_layout.addWidget(self.bonus_tree)
bonus_btn_row = QHBoxLayout()
self.delete_bonus_btn = QPushButton("Delete selected bonus files")
self.delete_bonus_btn.clicked.connect(self._delete_selected_bonuses)
bonus_btn_row.addWidget(self.delete_bonus_btn)
bonus_btn_row.addStretch()
bonus_layout.addLayout(bonus_btn_row)
layout.addWidget(bonus_group)
# Close button
close_btn = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
close_btn.rejected.connect(self.reject)
layout.addWidget(close_btn)
def _populate(self) -> None:
record = self.metadata.get_game(self.game_id)
if not record:
self.info_label.setText("No downloaded data found for this game.")
return
total_size = sum(i.size for i in record.installers) + sum(b.size for b in record.bonuses)
self.info_label.setText(
f"<b>{self.game_title}</b> — {len(record.installers)} installer file(s), "
f"{len(record.bonuses)} bonus file(s) — total {_fmt_size(total_size)}"
)
# Group installers by version for visual clarity
versions: dict[str, list[DownloadedInstaller]] = {}
for inst in record.installers:
versions.setdefault(inst.version, []).append(inst)
self.inst_tree.clear()
for version in sorted(versions):
ver_node = QTreeWidgetItem()
ver_node.setText(COL_FILE, f"Version {version}")
ver_node.setText(COL_VERSION, version)
bold = QFont()
bold.setBold(True)
ver_node.setFont(COL_FILE, bold)
ver_node.setData(COL_FILE, Qt.ItemDataRole.UserRole, ("version", version))
ver_size = sum(i.size for i in versions[version])
ver_node.setText(COL_SIZE, _fmt_size(ver_size))
for inst in versions[version]:
file_node = QTreeWidgetItem()
file_node.setText(COL_FILE, inst.filename)
file_node.setText(COL_VERSION, inst.version)
file_node.setText(COL_LANG, language_folder_name(inst.language))
file_node.setText(COL_SIZE, _fmt_size(inst.size))
file_node.setText(COL_DATE, inst.downloaded_at[:19].replace("T", " ") if inst.downloaded_at else "")
file_node.setData(COL_FILE, Qt.ItemDataRole.UserRole, ("installer", inst))
# Grey out if file no longer on disk
path = _installer_path(self.base_path, sanitize_folder_name(self.game_title), inst)
if not path.exists():
for col in range(5):
file_node.setForeground(col, self.palette().color(self.foregroundRole()).darker(150))
file_node.setToolTip(COL_FILE, "File not found on disk")
ver_node.addChild(file_node)
ver_node.setExpanded(True)
self.inst_tree.addTopLevelItem(ver_node)
# Bonus files
self.bonus_tree.clear()
for bonus in record.bonuses:
node = QTreeWidgetItem()
node.setText(0, bonus.filename)
node.setText(1, bonus.name)
node.setText(2, bonus.bonus_type)
node.setText(3, _fmt_size(bonus.size))
node.setData(0, Qt.ItemDataRole.UserRole, bonus)
bonus_path = self.base_path / sanitize_folder_name(self.game_title) / "Bonus" / bonus.filename
if not bonus_path.exists():
for col in range(4):
node.setForeground(col, self.palette().color(self.foregroundRole()).darker(150))
node.setToolTip(0, "File not found on disk")
self.bonus_tree.addTopLevelItem(node)
def _delete_selected_installers(self) -> None:
selected = [
item for item in self.inst_tree.selectedItems()
if item.data(COL_FILE, Qt.ItemDataRole.UserRole) and
item.data(COL_FILE, Qt.ItemDataRole.UserRole)[0] == "installer"
]
if not selected:
QMessageBox.information(self, "Delete", "Select one or more installer files first.")
return
reply = QMessageBox.question(
self,
"Delete files",
f"Delete {len(selected)} selected file(s) from disk?\nMetadata will be updated.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
record = self.metadata.get_game(self.game_id)
if not record:
return
deleted = 0
for item in selected:
inst: DownloadedInstaller = item.data(COL_FILE, Qt.ItemDataRole.UserRole)[1]
path = _installer_path(self.base_path, sanitize_folder_name(self.game_title), inst)
if path.exists():
path.unlink()
logger.info(f"Deleted {path}")
deleted += 1
record.installers = [i for i in record.installers if i.filename != inst.filename or i.version != inst.version]
# Update latest_version
if record.installers:
record.latest_version = sorted({i.version for i in record.installers})[-1]
else:
record.latest_version = ""
self.metadata.update_game(record)
self._populate()
QMessageBox.information(self, "Done", f"Deleted {deleted} file(s).")
def _delete_version_folder(self) -> None:
"""Delete an entire version directory after user selects which version."""
record = self.metadata.get_game(self.game_id)
if not record:
return
versions = sorted({i.version for i in record.installers})
if not versions:
QMessageBox.information(self, "Delete", "No versions found in metadata.")
return
# Pick version from selected item if any
selected = self.inst_tree.currentItem()
preselected_version = ""
if selected:
data = selected.data(COL_FILE, Qt.ItemDataRole.UserRole)
if data and data[0] == "version":
preselected_version = data[1]
elif data and data[0] == "installer":
preselected_version = data[1].version
version = preselected_version or versions[-1]
reply = QMessageBox.question(
self,
"Delete version folder",
f"Delete entire version folder '{version}' for '{self.game_title}'?\n\n"
f"Path: {self.base_path / sanitize_folder_name(self.game_title) / version}\n\n"
"This will remove all files in that folder from disk.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
version_dir = self.base_path / sanitize_folder_name(self.game_title) / version
if version_dir.exists():
shutil.rmtree(version_dir)
logger.info(f"Deleted version folder {version_dir}")
record.installers = [i for i in record.installers if i.version != version]
if record.installers:
record.latest_version = sorted({i.version for i in record.installers})[-1]
else:
record.latest_version = ""
self.metadata.update_game(record)
self._populate()
def _delete_selected_bonuses(self) -> None:
selected = self.bonus_tree.selectedItems()
if not selected:
QMessageBox.information(self, "Delete", "Select one or more bonus files first.")
return
reply = QMessageBox.question(
self,
"Delete bonus files",
f"Delete {len(selected)} bonus file(s) from disk?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
record = self.metadata.get_game(self.game_id)
if not record:
return
deleted = 0
for item in selected:
bonus = item.data(0, Qt.ItemDataRole.UserRole)
path = self.base_path / sanitize_folder_name(self.game_title) / "Bonus" / bonus.filename
if path.exists():
path.unlink()
logger.info(f"Deleted bonus {path}")
deleted += 1
record.bonuses = [b for b in record.bonuses if b.filename != bonus.filename]
self.metadata.update_game(record)
self._populate()
QMessageBox.information(self, "Done", f"Deleted {deleted} bonus file(s).")

70
src/ui/main_window.py Normal file
View File

@@ -0,0 +1,70 @@
"""Main application window with tab layout."""
from loguru import logger
from PySide6.QtWidgets import QMainWindow, QTabWidget
from src.api import GogApi
from src.auth import AuthManager
from src.config import AppConfig
from src.constants import APP_TITLE
from src.ui.tab_auth import AuthTab
from src.ui.tab_languages import LanguagesTab
from src.ui.tab_library import LibraryTab
from src.ui.tab_settings import SettingsTab
from src.ui.tab_status import StatusTab
class MainWindow(QMainWindow):
"""Main GOGUpdater window with 5 tabs."""
def __init__(self, auth: AuthManager, api: GogApi, config: AppConfig) -> None:
super().__init__()
self.auth = auth
self.api = api
self.config = config
self.setWindowTitle(APP_TITLE)
self.setMinimumSize(900, 600)
self._setup_tabs()
self._connect_signals()
def _setup_tabs(self) -> None:
self.tabs = QTabWidget()
self.setCentralWidget(self.tabs)
self.auth_tab = AuthTab(self.auth)
self.library_tab = LibraryTab(self.api, self.config)
self.languages_tab = LanguagesTab(self.config)
self.settings_tab = SettingsTab(self.api, self.config)
self.status_tab = StatusTab(self.api, self.config)
self.tabs.addTab(self.auth_tab, "Login")
self.tabs.addTab(self.library_tab, "Library")
self.tabs.addTab(self.languages_tab, "Languages")
self.tabs.addTab(self.settings_tab, "Settings")
self.tabs.addTab(self.status_tab, "Status")
self._update_tab_states()
def _connect_signals(self) -> None:
self.auth_tab.login_state_changed.connect(self._on_login_changed)
self.settings_tab.english_only_changed.connect(self._on_english_only_changed)
def _on_login_changed(self, logged_in: bool) -> None:
logger.info(f"Login state changed: logged_in={logged_in}")
self._update_tab_states()
def _on_english_only_changed(self, english_only: bool) -> None:
"""Enable/disable Languages tab based on english_only setting."""
logger.info(f"English only changed: {english_only}")
self._update_tab_states()
def _update_tab_states(self) -> None:
"""Enable/disable tabs based on login state and settings."""
logged_in = self.auth.is_logged_in
for i in range(1, self.tabs.count()):
self.tabs.setTabEnabled(i, logged_in)
# Languages tab (index 2) disabled when english_only
if logged_in and self.config.english_only:
self.tabs.setTabEnabled(2, False)

116
src/ui/tab_auth.py Normal file
View File

@@ -0,0 +1,116 @@
"""Authentication tab — GOG OAuth2 login flow."""
import webbrowser
from loguru import logger
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
from src.auth import LOGIN_URL, AuthManager
class AuthTab(QWidget):
"""Tab for GOG account authentication."""
login_state_changed = Signal(bool)
def __init__(self, auth: AuthManager, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.auth = auth
self._setup_ui()
self._update_status()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
# Status
self.status_label = QLabel()
self.status_label.setStyleSheet("font-size: 14px; font-weight: bold;")
layout.addWidget(self.status_label)
layout.addSpacing(20)
# Step 1: Open browser
step1_label = QLabel("1. Open GOG login page in your browser:")
layout.addWidget(step1_label)
self.open_browser_btn = QPushButton("Open GOG Login")
self.open_browser_btn.setFixedWidth(200)
self.open_browser_btn.clicked.connect(self._open_login)
layout.addWidget(self.open_browser_btn)
layout.addSpacing(10)
# Step 2: Paste code
step2_label = QLabel("2. After login, paste the authorization code from the URL:")
layout.addWidget(step2_label)
code_layout = QHBoxLayout()
self.code_input = QLineEdit()
self.code_input.setPlaceholderText("Paste authorization code here...")
self.code_input.returnPressed.connect(self._submit_code)
code_layout.addWidget(self.code_input)
self.submit_btn = QPushButton("Login")
self.submit_btn.clicked.connect(self._submit_code)
code_layout.addWidget(self.submit_btn)
layout.addLayout(code_layout)
layout.addSpacing(20)
# Logout
self.logout_btn = QPushButton("Logout")
self.logout_btn.setFixedWidth(200)
self.logout_btn.clicked.connect(self._logout)
layout.addWidget(self.logout_btn)
layout.addStretch()
def _update_status(self) -> None:
if self.auth.is_logged_in:
self.status_label.setText("Status: Logged in")
self.status_label.setStyleSheet("font-size: 14px; font-weight: bold; color: green;")
self.code_input.setEnabled(False)
self.submit_btn.setEnabled(False)
self.open_browser_btn.setEnabled(False)
self.logout_btn.setEnabled(True)
else:
self.status_label.setText("Status: Not logged in")
self.status_label.setStyleSheet("font-size: 14px; font-weight: bold; color: red;")
self.code_input.setEnabled(True)
self.submit_btn.setEnabled(True)
self.open_browser_btn.setEnabled(True)
self.logout_btn.setEnabled(False)
def _open_login(self) -> None:
logger.info("Opening GOG login page in browser")
webbrowser.open(LOGIN_URL)
def _submit_code(self) -> None:
code = self.code_input.text().strip()
if not code:
return
logger.info("Submitting authorization code")
if self.auth.exchange_code(code):
self._update_status()
self.login_state_changed.emit(True)
self.code_input.clear()
else:
logger.warning("Authorization code exchange failed")
QMessageBox.warning(self, "Login Failed", "Failed to authenticate. Check the code and try again.")
def _logout(self) -> None:
logger.info("User logging out")
self.auth.logout()
self._update_status()
self.login_state_changed.emit(False)

62
src/ui/tab_languages.py Normal file
View File

@@ -0,0 +1,62 @@
"""Languages tab — select which languages to manage installers for."""
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QLabel,
QListWidget,
QListWidgetItem,
QPushButton,
QVBoxLayout,
QWidget,
)
from loguru import logger
from src.config import AppConfig
from src.models import LANGUAGE_NAMES
class LanguagesTab(QWidget):
"""Tab for selecting which languages to download installers for."""
def __init__(self, config: AppConfig, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.config = config
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
label = QLabel("Select languages for which installers will be downloaded:")
layout.addWidget(label)
self.lang_list = QListWidget()
for code, name in LANGUAGE_NAMES.items():
item = QListWidgetItem(f"{name} ({code})")
item.setData(Qt.ItemDataRole.UserRole, code)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
checked = code in self.config.languages
item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
self.lang_list.addItem(item)
self.lang_list.itemChanged.connect(self._on_item_changed)
layout.addWidget(self.lang_list)
save_btn = QPushButton("Save")
save_btn.setFixedWidth(200)
save_btn.clicked.connect(self._save)
layout.addWidget(save_btn)
def _on_item_changed(self, item: QListWidgetItem) -> None:
self._save()
def _save(self) -> None:
selected: list[str] = []
for i in range(self.lang_list.count()):
item = self.lang_list.item(i)
if item and item.checkState() == Qt.CheckState.Checked:
code = item.data(Qt.ItemDataRole.UserRole)
selected.append(code)
self.config.languages = selected if selected else ["en"]
self.config.save()
logger.info(f"Languages updated: {self.config.languages}")

224
src/ui/tab_library.py Normal file
View File

@@ -0,0 +1,224 @@
"""Library tab — owned games with management checkboxes, DLCs as sub-items."""
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QHeaderView,
QLabel,
QPushButton,
QTabWidget,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from loguru import logger
from src.api import GogApi
from src.config import AppConfig
from src.models import InstallerPlatform
from src.ui.dialog_game_settings import GameSettingsDialog
COL_TITLE = 0
COL_ID = 1
COL_OVERRIDES = 2
def _make_tree() -> QTreeWidget:
tree = QTreeWidget()
tree.setColumnCount(3)
tree.setHeaderLabels(["Game Title", "Game ID", "Overrides"])
tree.header().setSectionResizeMode(COL_TITLE, QHeaderView.ResizeMode.Stretch)
tree.setRootIsDecorated(True)
tree.setSortingEnabled(True)
return tree
class LibraryTab(QWidget):
"""Tab showing owned games with checkboxes. DLCs appear as children of their parent game."""
managed_games_changed = Signal()
def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.api = api
self.config = config
self._updating = False
# 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: [],
InstallerPlatform.LINUX: [],
}
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
top_row = QPushButton("Refresh Library")
top_row.setFixedWidth(200)
top_row.clicked.connect(self.refresh_library)
layout.addWidget(top_row)
self.status_label = QLabel("Click 'Refresh Library' to load your games.")
layout.addWidget(self.status_label)
self.platform_tabs = QTabWidget()
self.tree_windows = _make_tree()
self.tree_linux = _make_tree()
self.platform_tabs.addTab(self.tree_windows, "Windows")
self.platform_tabs.addTab(self.tree_linux, "Linux")
layout.addWidget(self.platform_tabs)
self.tree_windows.itemChanged.connect(self._on_item_changed)
self.tree_linux.itemChanged.connect(self._on_item_changed)
self.tree_windows.itemDoubleClicked.connect(self._on_item_double_clicked)
self.tree_linux.itemDoubleClicked.connect(self._on_item_double_clicked)
self._update_platform_tab_visibility()
def _update_platform_tab_visibility(self) -> None:
self.platform_tabs.setTabVisible(0, bool(self.config.windows_path))
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."""
self.status_label.setText("Loading library...")
self.tree_windows.clear()
self.tree_linux.clear()
self._update_platform_tab_visibility()
logger.info("Refreshing game library")
# TODO: Run in a thread to avoid blocking the GUI
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
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)
for gid in owned_ids:
product = self.api.get_product_info(gid)
if not product:
games_data.append((str(gid), f"Game {gid}", [], set()))
continue
title = product.get("title", f"Game {gid}")
dlcs: list[tuple[str, str]] = []
for dlc in product.get("expanded_dlcs", []):
dlc_id = str(dlc.get("id", ""))
if dlc_id in owned_set:
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((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())
self._updating = True
self.tree_windows.setSortingEnabled(False)
self.tree_linux.setSortingEnabled(False)
win_count = 0
lin_count = 0
for game_id, title, dlcs, platforms in games_data:
managed = self.config.is_game_managed(game_id)
check = Qt.CheckState.Checked if managed else Qt.CheckState.Unchecked
if "windows" in platforms and self.config.windows_path:
node = self._make_node(game_id, title, check)
self.tree_windows.addTopLevelItem(node)
for dlc_id, dlc_title in dlcs:
child = self._make_node(dlc_id, dlc_title, check)
node.addChild(child)
win_count += 1
if "linux" in platforms and self.config.linux_path:
node = self._make_node(game_id, title, check)
self.tree_linux.addTopLevelItem(node)
for dlc_id, dlc_title in dlcs:
child = self._make_node(dlc_id, dlc_title, check)
node.addChild(child)
lin_count += 1
self.tree_windows.setSortingEnabled(True)
self.tree_linux.setSortingEnabled(True)
self._updating = False
logger.info(f"Library loaded: {win_count} Windows, {lin_count} Linux games")
self.status_label.setText(
f"Loaded {win_count} Windows / {lin_count} Linux games. Double-click to configure per-game settings."
)
def _make_node(self, game_id: str, title: str, check: Qt.CheckState) -> QTreeWidgetItem:
node = QTreeWidgetItem()
node.setText(COL_TITLE, title)
node.setText(COL_ID, game_id)
node.setFlags(node.flags() | Qt.ItemFlag.ItemIsUserCheckable)
node.setCheckState(COL_TITLE, check)
self._update_override_label(node, game_id)
return node
def _on_item_changed(self, item: QTreeWidgetItem, column: int) -> None:
if self._updating or column != COL_TITLE:
return
game_id = item.text(COL_ID)
managed = item.checkState(COL_TITLE) == Qt.CheckState.Checked
self._updating = True
if item.parent() is None:
logger.debug(f"Game {game_id} managed={managed}")
self.config.set_game_managed(game_id, managed)
for i in range(item.childCount()):
child = item.child(i)
if child:
child.setCheckState(COL_TITLE, item.checkState(COL_TITLE))
self.config.set_game_managed(child.text(COL_ID), managed)
else:
logger.debug(f"DLC {game_id} managed={managed}")
self.config.set_game_managed(game_id, managed)
self._updating = False
self.managed_games_changed.emit()
def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
game_id = item.text(COL_ID)
game_title = item.text(COL_TITLE)
dialog = GameSettingsDialog(game_id, game_title, self.config, self)
if dialog.exec():
# Update override label in both trees
for tree in (self.tree_windows, self.tree_linux):
for i in range(tree.topLevelItemCount()):
top = tree.topLevelItem(i)
if top and top.text(COL_ID) == game_id:
self._update_override_label(top, game_id)
if top:
for j in range(top.childCount()):
child = top.child(j)
if child and child.text(COL_ID) == game_id:
self._update_override_label(child, game_id)
def _update_override_label(self, item: QTreeWidgetItem, game_id: str) -> None:
gs = self.config.get_game_settings(game_id)
if gs.is_default():
item.setText(COL_OVERRIDES, "")
return
parts: list[str] = []
if gs.english_only is not None:
parts.append("EN only" if gs.english_only else "multilang")
if gs.languages is not None:
parts.append(f"{len(gs.languages)} lang(s)")
if gs.include_bonus is not None:
parts.append("bonus" if gs.include_bonus else "no bonus")
item.setText(COL_OVERRIDES, ", ".join(parts))

167
src/ui/tab_settings.py Normal file
View File

@@ -0,0 +1,167 @@
"""Settings tab — installer paths configuration."""
from PySide6.QtCore import Signal
from PySide6.QtWidgets import (
QCheckBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QVBoxLayout,
QWidget,
)
from loguru import logger
from src.api import GogApi
from src.config import AppConfig, MetadataStore
class SettingsTab(QWidget):
"""Tab for configuring installer storage paths."""
english_only_changed = Signal(bool)
def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.api = api
self.config = config
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
form = QFormLayout()
# Windows installers path
win_layout = QHBoxLayout()
self.win_path_input = QLineEdit(self.config.windows_path)
self.win_path_input.setPlaceholderText("Path for Windows installers (.exe)")
win_layout.addWidget(self.win_path_input)
win_browse_btn = QPushButton("Browse...")
win_browse_btn.clicked.connect(lambda: self._browse("windows"))
win_layout.addWidget(win_browse_btn)
form.addRow("Windows installers:", win_layout)
# Linux installers path
linux_layout = QHBoxLayout()
self.linux_path_input = QLineEdit(self.config.linux_path)
self.linux_path_input.setPlaceholderText("Path for Linux installers (.sh)")
linux_layout.addWidget(self.linux_path_input)
linux_browse_btn = QPushButton("Browse...")
linux_browse_btn.clicked.connect(lambda: self._browse("linux"))
linux_layout.addWidget(linux_browse_btn)
form.addRow("Linux installers:", linux_layout)
layout.addLayout(form)
layout.addSpacing(10)
# English only checkbox
self.english_only_cb = QCheckBox("English only (disable multilingual support)")
self.english_only_cb.setChecked(self.config.english_only)
self.english_only_cb.toggled.connect(self._on_english_only_toggled)
layout.addWidget(self.english_only_cb)
# Include bonus content checkbox
self.include_bonus_cb = QCheckBox("Include bonus content (soundtracks, wallpapers, manuals, ...)")
self.include_bonus_cb.setChecked(self.config.include_bonus)
self.include_bonus_cb.toggled.connect(self._on_include_bonus_toggled)
layout.addWidget(self.include_bonus_cb)
layout.addSpacing(20)
# Buttons row
btn_layout = QHBoxLayout()
save_btn = QPushButton("Save Settings")
save_btn.setFixedWidth(200)
save_btn.clicked.connect(self._save)
btn_layout.addWidget(save_btn)
scan_btn = QPushButton("Scan Existing Installers")
scan_btn.setFixedWidth(200)
scan_btn.clicked.connect(self._scan_existing)
btn_layout.addWidget(scan_btn)
btn_layout.addStretch()
layout.addLayout(btn_layout)
self.status_label = QLabel("")
layout.addWidget(self.status_label)
layout.addStretch()
def _browse(self, target: str) -> None:
path = QFileDialog.getExistingDirectory(self, f"Select {target} installers directory")
if path:
if target == "windows":
self.win_path_input.setText(path)
else:
self.linux_path_input.setText(path)
def _on_english_only_toggled(self, checked: bool) -> None:
self.config.english_only = checked
self.config.save()
self.english_only_changed.emit(checked)
logger.info(f"English only: {checked}")
def _on_include_bonus_toggled(self, checked: bool) -> None:
self.config.include_bonus = checked
self.config.save()
logger.info(f"Include bonus content: {checked}")
def _save(self) -> None:
self.config.windows_path = self.win_path_input.text().strip()
self.config.linux_path = self.linux_path_input.text().strip()
self.config.english_only = self.english_only_cb.isChecked()
self.config.include_bonus = self.include_bonus_cb.isChecked()
self.config.save()
logger.info(f"Settings saved: windows={self.config.windows_path}, linux={self.config.linux_path}")
self.status_label.setText("Settings saved.")
def _scan_existing(self) -> None:
"""Scan configured paths for already downloaded installers."""
# Build title -> game_id mapping from owned games
self.status_label.setText("Fetching game library for matching...")
try:
owned_games = self.api.get_owned_games()
except Exception as e:
logger.error(f"Failed to fetch owned games for scan: {e}")
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}
logger.info(f"Scan: loaded {len(title_to_id)} owned games for matching")
total_detected = 0
scanned_paths: list[str] = []
for path in [self.config.windows_path, self.config.linux_path]:
if not path:
continue
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
if not scanned_paths:
self.status_label.setText("No paths configured. Set Windows/Linux paths first.")
return
msg = f"Scan complete. Detected {total_detected} game(s) with existing installers."
self.status_label.setText(msg)
logger.info(msg)
if total_detected > 0:
QMessageBox.information(
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.",
)

569
src/ui/tab_status.py Normal file
View File

@@ -0,0 +1,569 @@
"""Status tab — installer status overview, update checks, downloads, pruning."""
from datetime import datetime, timezone
from PySide6.QtCore import Qt
from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QHBoxLayout,
QHeaderView,
QLabel,
QMessageBox,
QProgressBar,
QPushButton,
QTabWidget,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
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.models import (
BonusContent,
GameStatus,
GameStatusInfo,
InstallerPlatform,
LanguageVersionInfo,
language_folder_name,
)
from src.version_compare import CompareResult, compare_versions, format_comparison_question
STATUS_COLORS: dict[GameStatus, str] = {
GameStatus.UP_TO_DATE: "#2e7d32",
GameStatus.UPDATE_AVAILABLE: "#e65100",
GameStatus.NOT_DOWNLOADED: "#1565c0",
GameStatus.UNKNOWN: "#757575",
GameStatus.UNVERSIONED: "#f9a825",
}
COL_NAME = 0
COL_CURRENT = 1
COL_LATEST = 2
COL_STATUS = 3
COL_LANGS = 4
COL_BONUS = 5
def _make_tree() -> QTreeWidget:
tree = QTreeWidget()
tree.setColumnCount(6)
tree.setHeaderLabels(["Game", "Current Version", "Latest Version", "Status", "Language", "Bonus"])
tree.header().setSectionResizeMode(COL_NAME, QHeaderView.ResizeMode.Stretch)
tree.setRootIsDecorated(True)
tree.setSortingEnabled(True)
return tree
class StatusTab(QWidget):
"""Tab showing installer status with check/download/prune controls."""
def __init__(self, api: GogApi, config: AppConfig, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.api = api
self.config = config
# per-platform status items
self.status_items: dict[InstallerPlatform, list[GameStatusInfo]] = {
InstallerPlatform.WINDOWS: [],
InstallerPlatform.LINUX: [],
}
self._setup_ui()
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
# Controls
controls = QHBoxLayout()
self.check_btn = QPushButton("Check for Updates")
self.check_btn.clicked.connect(self.check_updates)
controls.addWidget(self.check_btn)
self.download_btn = QPushButton("Download Updates")
self.download_btn.clicked.connect(self.download_updates)
self.download_btn.setEnabled(False)
controls.addWidget(self.download_btn)
self.prune_btn = QPushButton("Prune Old Versions")
self.prune_btn.clicked.connect(self.prune_versions)
controls.addWidget(self.prune_btn)
self.prune_keep_combo = QComboBox()
self.prune_keep_combo.addItems(["Keep 1", "Keep 2", "Keep 3"])
controls.addWidget(self.prune_keep_combo)
self.show_unknown_cb = QCheckBox("Show unversioned/unknown")
self.show_unknown_cb.setChecked(False)
self.show_unknown_cb.toggled.connect(self._repopulate_current)
controls.addWidget(self.show_unknown_cb)
controls.addStretch()
layout.addLayout(controls)
# Status label
self.status_label = QLabel("Click 'Check for Updates' to scan managed games.")
layout.addWidget(self.status_label)
# Progress bar
self.progress_bar = QProgressBar()
self.progress_bar.setVisible(False)
layout.addWidget(self.progress_bar)
# Platform sub-tabs
self.platform_tabs = QTabWidget()
self.tree_windows = _make_tree()
self.tree_linux = _make_tree()
self.platform_tabs.addTab(self.tree_windows, "Windows")
self.platform_tabs.addTab(self.tree_linux, "Linux")
self.tree_windows.itemDoubleClicked.connect(self._on_item_double_clicked)
self.tree_linux.itemDoubleClicked.connect(self._on_item_double_clicked)
layout.addWidget(self.platform_tabs)
self._update_platform_tab_visibility()
def _update_platform_tab_visibility(self) -> None:
"""Show/hide platform tabs based on configured paths."""
has_win = bool(self.config.windows_path)
has_lin = bool(self.config.linux_path)
self.platform_tabs.setTabVisible(0, has_win)
self.platform_tabs.setTabVisible(1, has_lin)
def _current_platform(self) -> InstallerPlatform:
idx = self.platform_tabs.currentIndex()
return InstallerPlatform.WINDOWS if idx == 0 else InstallerPlatform.LINUX
def _tree_for(self, platform: InstallerPlatform) -> QTreeWidget:
return self.tree_windows if platform == InstallerPlatform.WINDOWS else self.tree_linux
def _base_path_for(self, platform: InstallerPlatform) -> str:
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
if not managed_ids:
self.status_label.setText("No managed games. Go to Library tab to select games.")
return
self.check_btn.setEnabled(False)
self.status_label.setText("Checking updates...")
for items in self.status_items.values():
items.clear()
self.tree_windows.clear()
self.tree_linux.clear()
self._update_platform_tab_visibility()
# TODO: Run in a thread to avoid blocking the GUI
# Verify metadata integrity — remove entries for files deleted outside the app
for platform in InstallerPlatform:
base_path = self._base_path_for(platform)
if base_path:
metadata = MetadataStore(base_path)
stale = metadata.verify_metadata()
if stale:
logger.info(f"Cleaned {stale} stale metadata entries from {base_path}")
owned_set = self.api.get_owned_ids_set()
managed_set = set(managed_ids)
# First pass: collect all DLC IDs and cache products
dlc_ids: set[str] = 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
for dlc in product.get("expanded_dlcs", []):
dlc_ids.add(str(dlc.get("id", "")))
for platform in InstallerPlatform:
base_path = self._base_path_for(platform)
if not base_path:
continue
metadata = MetadataStore(base_path)
items_list = self.status_items[platform]
for game_id in managed_ids:
if game_id in dlc_ids:
continue
product = products_cache.get(game_id)
if not product:
items_list.append(GameStatusInfo(
game_id=game_id,
name=f"Game {game_id}",
current_version="",
latest_version="",
status=GameStatus.UNKNOWN,
))
continue
game_name = product.get("title", f"Game {game_id}")
game_status = self._build_game_status(game_id, game_name, product, platform, metadata)
for dlc in product.get("expanded_dlcs", []):
dlc_id = str(dlc.get("id", ""))
if dlc_id not in owned_set or dlc_id not in managed_set:
continue
dlc_name = dlc.get("title", f"DLC {dlc_id}")
dlc_status = self._build_game_status(dlc_id, dlc_name, dlc, platform, metadata, parent_name=game_name)
game_status.dlcs.append(dlc_status)
items_list.append(game_status)
self._populate_tree(InstallerPlatform.WINDOWS)
self._populate_tree(InstallerPlatform.LINUX)
self.download_btn.setEnabled(self._has_downloadable_items())
self.check_btn.setEnabled(True)
total = sum(len(items) for items in self.status_items.values())
self.status_label.setText(f"Checked {total} game entries across platforms.")
def _build_game_status(
self,
game_id: str,
game_name: str,
product: dict,
platform: InstallerPlatform,
metadata: MetadataStore,
parent_name: str = "",
) -> GameStatusInfo:
"""Build GameStatusInfo with per-language version tracking."""
platform_key = "windows" if platform == InstallerPlatform.WINDOWS else "linux"
installers = product.get("downloads", {}).get("installers", [])
lang_versions: dict[str, str] = {}
for inst in installers:
if inst.get("os") == platform_key:
lang = inst.get("language", "en")
version = inst.get("version") or ""
effective_langs = self.config.get_effective_languages(game_id)
if lang in effective_langs or not effective_langs:
lang_versions[lang] = version
record = metadata.get_game(game_id)
current_version = record.latest_version if record else ""
language_infos: list[LanguageVersionInfo] = []
for lang, latest_ver in lang_versions.items():
lang_status = self._determine_status_with_compare(current_version, latest_ver, game_name, lang)
language_infos.append(LanguageVersionInfo(
language=lang,
current_version=current_version,
latest_version=latest_ver,
status=lang_status,
))
if language_infos:
overall_status = self._worst_status(language_infos)
representative_latest = language_infos[0].latest_version
else:
overall_status = GameStatus.UNKNOWN
representative_latest = ""
all_same_version = len({lv.latest_version for lv in language_infos}) <= 1
bonus_available = 0
bonus_downloaded = 0
if self.config.get_effective_include_bonus(game_id) and language_infos:
bonus_items = self.api.get_bonus_content(game_id, product)
bonus_available = len(bonus_items)
if record:
bonus_downloaded = len(record.bonuses)
return GameStatusInfo(
game_id=game_id,
name=game_name,
current_version=current_version,
latest_version=representative_latest if all_same_version else "",
status=overall_status,
language_versions=language_infos,
last_checked=datetime.now(timezone.utc).isoformat(),
parent_name=parent_name,
bonus_available=bonus_available,
bonus_downloaded=bonus_downloaded,
)
@staticmethod
def _is_non_version(value: str) -> bool:
return value.strip().lower() in {"bonus", "n/a", "na", "none", ""}
def _determine_status_with_compare(
self, current: str, latest: str, game_name: str, language: str,
) -> GameStatus:
if not latest or self._is_non_version(latest):
return GameStatus.UNVERSIONED
if not current:
return GameStatus.NOT_DOWNLOADED
if self._is_non_version(current):
return GameStatus.NOT_DOWNLOADED
result = compare_versions(current, latest)
if result == CompareResult.EQUAL:
return GameStatus.UP_TO_DATE
elif result == CompareResult.OLDER:
return GameStatus.UPDATE_AVAILABLE
elif result == CompareResult.NEWER:
return GameStatus.UP_TO_DATE
else:
question = format_comparison_question(game_name, language, current, latest)
reply = QMessageBox.question(
self,
"Version Comparison",
question,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
return GameStatus.UPDATE_AVAILABLE
return GameStatus.UP_TO_DATE
def _worst_status(self, lang_infos: list[LanguageVersionInfo]) -> GameStatus:
priority = [
GameStatus.UPDATE_AVAILABLE,
GameStatus.NOT_DOWNLOADED,
GameStatus.UNVERSIONED,
GameStatus.UNKNOWN,
GameStatus.UP_TO_DATE,
]
for status in priority:
if any(lv.status == status for lv in lang_infos):
return status
return GameStatus.UNKNOWN
def _has_downloadable_items(self) -> bool:
downloadable = {GameStatus.UPDATE_AVAILABLE, GameStatus.NOT_DOWNLOADED}
for items in self.status_items.values():
for item in items:
if item.status in downloadable:
return True
if any(dlc.status in downloadable for dlc in item.dlcs):
return True
if item.bonus_available > item.bonus_downloaded:
return True
return False
def _should_show(self, info: GameStatusInfo) -> bool:
if info.status in (GameStatus.UNKNOWN, GameStatus.UNVERSIONED):
return self.show_unknown_cb.isChecked()
return True
def _repopulate_current(self) -> None:
"""Repopulate only the currently visible platform tree."""
self._populate_tree(self._current_platform())
def _populate_tree(self, platform: InstallerPlatform) -> None:
tree = self._tree_for(platform)
tree.setSortingEnabled(False)
tree.clear()
for item in self.status_items[platform]:
visible_dlcs = [dlc for dlc in item.dlcs if self._should_show(dlc)]
if not self._should_show(item) and not visible_dlcs:
continue
game_node = self._create_game_tree_item(item)
tree.addTopLevelItem(game_node)
for dlc in visible_dlcs:
dlc_node = self._create_game_tree_item(dlc)
game_node.addChild(dlc_node)
if visible_dlcs:
game_node.setExpanded(True)
tree.setSortingEnabled(True)
def _create_game_tree_item(self, info: GameStatusInfo) -> QTreeWidgetItem:
tree_item = QTreeWidgetItem()
tree_item.setText(COL_NAME, info.name)
tree_item.setData(COL_STATUS, Qt.ItemDataRole.UserRole, info.game_id)
has_different_versions = len({lv.latest_version for lv in info.language_versions}) > 1
if has_different_versions:
tree_item.setText(COL_CURRENT, info.current_version or "-")
tree_item.setText(COL_LATEST, "(per language)")
tree_item.setText(COL_STATUS, info.status_text)
tree_item.setText(COL_LANGS, "")
tree_item.setForeground(COL_STATUS, QBrush(QColor(STATUS_COLORS.get(info.status, "#000000"))))
for lv in info.language_versions:
lang_node = QTreeWidgetItem()
lang_node.setText(COL_NAME, f" {language_folder_name(lv.language)}")
lang_node.setText(COL_CURRENT, lv.current_version or "-")
lang_node.setText(COL_LATEST, lv.latest_version or "-")
lang_node.setText(COL_STATUS, GameStatusInfo(
game_id="", name="", current_version="", latest_version="", status=lv.status,
).status_text)
lang_node.setText(COL_LANGS, lv.language)
lang_node.setForeground(COL_STATUS, QBrush(QColor(STATUS_COLORS.get(lv.status, "#000000"))))
tree_item.addChild(lang_node)
tree_item.setExpanded(True)
else:
tree_item.setText(COL_CURRENT, info.current_version or "-")
latest = info.language_versions[0].latest_version if info.language_versions else "-"
tree_item.setText(COL_LATEST, latest or "-")
tree_item.setText(COL_STATUS, info.status_text)
langs = ", ".join(language_folder_name(lv.language) for lv in info.language_versions)
tree_item.setText(COL_LANGS, langs)
tree_item.setForeground(COL_STATUS, QBrush(QColor(STATUS_COLORS.get(info.status, "#000000"))))
if info.bonus_available > 0:
tree_item.setText(COL_BONUS, f"{info.bonus_downloaded}/{info.bonus_available}")
color = "#e65100" if info.bonus_downloaded < info.bonus_available else "#2e7d32"
tree_item.setForeground(COL_BONUS, QBrush(QColor(color)))
return tree_item
def download_updates(self) -> None:
"""Download updates for the currently visible platform."""
platform = self._current_platform()
base_path = self._base_path_for(platform)
if not base_path:
self.status_label.setText("No path configured for this platform.")
return
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)
if not to_download:
self.status_label.setText("Nothing to download.")
return
self.download_btn.setEnabled(False)
self.check_btn.setEnabled(False)
self.progress_bar.setVisible(True)
self.progress_bar.setMaximum(len(to_download))
self.progress_bar.setValue(0)
# TODO: Run in a thread to avoid blocking the GUI
completed = 0
metadata = MetadataStore(base_path)
downloader = InstallerDownloader(self.api, metadata)
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 content for this platform
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.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)
self.check_btn.setEnabled(True)
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
def _on_item_double_clicked(self, item: QTreeWidgetItem, column: int) -> None:
"""Open version management dialog for the clicked game."""
game_id = item.data(COL_STATUS, Qt.ItemDataRole.UserRole)
if not game_id:
return # language sub-row, no game_id stored
game_name = item.text(COL_NAME)
base_path = self._base_path_for(self._current_platform())
if not base_path:
return
metadata = MetadataStore(base_path)
dialog = GameVersionsDialog(game_id, game_name, metadata, self)
dialog.exec()
def prune_versions(self) -> None:
"""Remove old installer versions for the currently visible platform."""
platform = self._current_platform()
base_path = self._base_path_for(platform)
if not base_path:
self.status_label.setText("No path configured for this platform.")
return
keep = self.prune_keep_combo.currentIndex() + 1
reply = QMessageBox.question(
self,
"Prune Old Versions",
f"This will delete all but the {keep} most recent version(s) of each managed game "
f"in the {platform.value.capitalize()} path.\n\nContinue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
metadata = MetadataStore(base_path)
total_deleted = sum(
len(metadata.prune_old_versions(game_id, keep_latest=keep))
for game_id in self.config.managed_game_ids
)
self.status_label.setText(f"Pruned {total_deleted} old version(s).")

81
src/version_compare.py Normal file
View File

@@ -0,0 +1,81 @@
"""Version comparison with fallback to user prompt for ambiguous cases."""
import re
from enum import Enum
from loguru import logger
class CompareResult(Enum):
OLDER = "older" # current < latest → update available
EQUAL = "equal" # current == latest → up to date
NEWER = "newer" # current > latest → somehow ahead
AMBIGUOUS = "ambiguous" # can't determine → ask user
# Pattern to strip GOG-specific suffixes like "(gog-3)", "(Galaxy)", etc.
_GOG_SUFFIX_RE = re.compile(r"\s*\(.*?\)\s*$")
def _normalize(version: str) -> str:
"""Strip GOG-specific suffixes and whitespace."""
return _GOG_SUFFIX_RE.sub("", version).strip()
def _parse_numeric_parts(version: str) -> list[int] | None:
"""Try to parse version as dot-separated integers. Returns None if not possible."""
normalized = _normalize(version)
if not normalized:
return None
parts = normalized.split(".")
try:
return [int(p) for p in parts]
except ValueError:
return None
def compare_versions(current: str, latest: str) -> CompareResult:
"""Compare two version strings.
Tries numeric comparison first. If both versions can be parsed as
dot-separated integers (after stripping GOG suffixes), compares numerically.
If versions are identical strings, returns EQUAL.
Otherwise returns AMBIGUOUS — caller should ask the user.
"""
if not current or not latest:
return CompareResult.AMBIGUOUS
# Exact string match
if current == latest:
return CompareResult.EQUAL
# Normalized string match
norm_current = _normalize(current)
norm_latest = _normalize(latest)
if norm_current == norm_latest:
return CompareResult.EQUAL
# Try numeric comparison
current_parts = _parse_numeric_parts(current)
latest_parts = _parse_numeric_parts(latest)
if current_parts is not None and latest_parts is not None:
# Pad shorter list with zeros for fair comparison
max_len = max(len(current_parts), len(latest_parts))
current_padded = current_parts + [0] * (max_len - len(current_parts))
latest_padded = latest_parts + [0] * (max_len - len(latest_parts))
if current_padded < latest_padded:
return CompareResult.OLDER
elif current_padded > latest_padded:
return CompareResult.NEWER
else:
return CompareResult.EQUAL
logger.debug(f"Ambiguous version comparison: '{current}' vs '{latest}'")
return CompareResult.AMBIGUOUS
def format_comparison_question(game_name: str, language: str, current: str, latest: str) -> str:
"""Format a human-readable question for ambiguous version comparison."""
return f"{game_name} [{language}]\n\nIs '{latest}' newer than '{current}'?"