"""Installer file downloader with progress reporting and resume support.""" import time from datetime import UTC, datetime 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). """ folder = self._folder_for(installer.game_id, 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 / folder / version else: lang_folder = language_folder_name(installer.language) target_dir = self.metadata.base_path / folder / 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, folder, 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 # Only append if the server honored the Range request (206). A plain 200 # means the body starts from byte 0, so appending would corrupt the file. if existing_size > 0 and response.status_code == 206: downloaded = existing_size mode = "ab" total_size = int(response.headers.get("content-length", 0)) + existing_size else: if existing_size > 0: logger.warning(f"Server ignored Range for {real_filename}, restarting download") downloaded = 0 mode = "wb" total_size = int(response.headers.get("content-length", 0)) last_time = time.monotonic() last_downloaded = downloaded 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, folder, 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.""" folder = self._folder_for(bonus.game_id, 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 / folder / "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, folder, 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, folder, 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 # Only append if the server honored the Range request (206). if existing_size > 0 and response.status_code == 206: downloaded = existing_size mode = "ab" total_size = int(response.headers.get("content-length", 0)) + existing_size else: if existing_size > 0: logger.warning(f"Server ignored Range for {real_filename}, restarting download") downloaded = 0 mode = "wb" total_size = int(response.headers.get("content-length", 0)) last_time = time.monotonic() last_downloaded = downloaded 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, folder, real_filename) logger.info(f"Downloaded bonus {real_filename} ({downloaded} bytes)") if callback: callback.on_finished(True, "Download complete") return True def _folder_for(self, game_id: str, game_name: str) -> str: """Pinned download folder for a game: its recorded folder, else the sanitized title.""" record = self.metadata.get_game(game_id) if record and record.folder: return record.folder return sanitize_folder_name(game_name) def _record_bonus(self, bonus: BonusContent, folder: str, real_filename: str) -> None: """Record the bonus download in metadata store.""" record = self.metadata.get_game(bonus.game_id) if not record: record = GameRecord( game_id=bonus.game_id, name=folder, ) if not record.folder: record.folder = folder downloaded = DownloadedBonus( filename=real_filename, name=bonus.name, bonus_type=bonus.bonus_type, size=bonus.size, downloaded_at=datetime.now(UTC).isoformat(), ) record.bonuses = [b for b in record.bonuses if b.filename != real_filename] record.bonuses.append(downloaded) record.last_checked = datetime.now(UTC).isoformat() self.metadata.update_game(record) def _record_download(self, installer: InstallerInfo, folder: str, real_filename: str) -> None: """Record the download in metadata store.""" record = self.metadata.get_game(installer.game_id) if not record: record = GameRecord( game_id=installer.game_id, name=folder, latest_version=installer.version, ) if not record.folder: record.folder = folder downloaded = DownloadedInstaller( filename=real_filename, size=installer.size, version=installer.version, language=installer.language, installer_type=installer.installer_type, downloaded_at=datetime.now(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(UTC).isoformat() self.metadata.update_game(record)