Initial release — PySide6 app for automatic GOG offline installer management
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user