Initial release — PySide6 app for automatic GOG offline installer management
This commit is contained in:
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
2
src/_version.py
Normal file
2
src/_version.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Auto-generated — do not edit manually."""
|
||||
__version__ = "0.1.0"
|
||||
250
src/api.py
Normal file
250
src/api.py
Normal 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
129
src/auth.py
Normal 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
340
src/config.py
Normal 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
80
src/constants.py
Normal 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
284
src/downloader.py
Normal 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
338
src/models.py
Normal 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
0
src/ui/__init__.py
Normal file
128
src/ui/dialog_game_settings.py
Normal file
128
src/ui/dialog_game_settings.py
Normal 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()
|
||||
325
src/ui/dialog_game_versions.py
Normal file
325
src/ui/dialog_game_versions.py
Normal 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
70
src/ui/main_window.py
Normal 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
116
src/ui/tab_auth.py
Normal 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
62
src/ui/tab_languages.py
Normal 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
224
src/ui/tab_library.py
Normal 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
167
src/ui/tab_settings.py
Normal 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
569
src/ui/tab_status.py
Normal 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
81
src/version_compare.py
Normal 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}'?"
|
||||
Reference in New Issue
Block a user