Initial release — PySide6 app for automatic GOG offline installer management

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