Files
GOGUpdater/src/downloader.py
T

311 lines
12 KiB
Python

"""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)