311 lines
12 KiB
Python
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)
|