galaxy_dl is a specialized Python library for downloading GOG Galaxy CDN files. It now features a unified downloader that handles both V1 (legacy main.bin blobs) and V2 (modern chunks) with multi-threaded downloads for both formats.
- Multi-threaded V1 downloads using HTTP Range requests (matching heroic-gogdl)
- Unified
GalaxyDownloaderclass - auto-detects V1 vs V2 - Simplified API - one downloader for all manifest types
- Equal performance - both V1 and V2 saturate bandwidth
galaxy_dl/
├── __init__.py # Package exports
├── constants.py # API endpoints, defaults
├── auth.py # OAuth2 authentication
├── models.py # Data models (DepotItem, Chunks, Manifest, Patch, FilePatchDiff)
├── diff.py # ManifestDiff for comparing manifests
├── utils.py # Helper functions (hashing, paths, range headers)
├── api.py # Galaxy API client
├── downloader.py # UNIFIED downloader (V1 + V2, both multi-threaded)
├── dependencies.py # Dependency management
├── gui_login.py # GUI-based login helper
└── cli.py # Command-line interface
@dataclass
class DepotItem:
# Common fields
path: str
md5: str # Hash of final extracted file
total_size_compressed: int
total_size_uncompressed: int
chunks: List[DepotItemChunk] # V2: actual chunks
product_id: str
is_dependency: bool
# V1-specific fields
is_v1_blob: bool = False # Flag for V1 blob
v1_offset: int = 0 # Offset within main.bin (for extraction)
v1_size: int = 0 # Size within main.bin (for extraction)
v1_blob_md5: str = "" # MD5 hash of main.bin BLOB itself
v1_blob_path: str = "main.bin" # Path to blob file
# V2-specific fields (Small Files Container)
is_small_files_container: bool = False # Is this an SFC
is_in_sfc: bool = False # Is file inside an SFC
sfc_offset: int = 0 # Offset within SFC
sfc_size: int = 0 # Size within SFCHash Clarification:
- V1:
v1_blob_md5= hash ofmain.binblob,md5= hash of extracted file - V2:
md5= hash of final assembled file,chunk.md5_compressed= hash of each chunk
class GalaxyDownloader:
"""
Unified downloader for both V1 and V2 manifests.
V1: Downloads main.bin using HTTP range requests (multi-threaded)
V2: Downloads individual chunks (multi-threaded)
"""
def __init__(self, api: GalaxyAPI, max_workers: int = 4):
...
def download_item(self, item: DepotItem, output_dir: str,
cdn_urls: Optional[List[str]] = None,
verify_hash: bool = True,
progress_callback: Optional[Callable[[int, int], None]] = None,
raw_mode: bool = False,
sfc_data: Optional[bytes] = None) -> str:
"""Auto-detects V1 vs V2 and uses appropriate method."""
if item.is_v1_blob:
return self._download_v1_blob(...) # Multi-threaded range
elif item.is_in_sfc and sfc_data:
# Extract from Small Files Container
else:
return self._download_v2_item(...) # Multi-threaded chunks
def _download_v1_blob(self, item: DepotItem, ...) -> str:
"""
V1: Split main.bin into ~10MB chunks, download with Range headers.
Example: 500MB file -> 50 chunks of 10MB each
- Chunk 0: Range: bytes=0-10485759
- Chunk 1: Range: bytes=10485760-20971519
- ... (parallel with ThreadPoolExecutor)
"""
def _download_v1_file(self, item: DepotItem, ...) -> str:
"""
V1: Extract individual file from main.bin using v1_offset/v1_size.
Uses single range request for the specific file.
"""
def _download_v2_item(self, item: DepotItem, ...) -> str:
"""
V2: Download pre-chunked ~10MB pieces, decompress with zlib.
Each chunk already ~10MB from manifest, download in parallel.
"""
def _download_v2_item_raw(self, item: DepotItem, ...) -> str:
"""
V2 Raw mode: Download chunks without decompression.
Saves compressed chunks to v2/store/ structure.
"""1. Get main.bin URL from CDN
2. Calculate number of 10MB chunks: num_chunks = total_size / 10MB
3. Create RangeDownloadTask for each chunk:
- Task 0: offset=0, size=10MB
- Task 1: offset=10MB, size=10MB
- Task N: offset=N*10MB, size=remaining
4. ThreadPoolExecutor submits all tasks in parallel
5. Each worker:
- Sets Range header: bytes=offset-(offset+size-1)
- Downloads chunk data
- Returns bytes
6. Main thread writes each chunk to correct offset in file
7. Verify v1_blob_md5 of complete main.bin
1. Get chunk URLs from CDN (using galaxy_path from md5_compressed)
2. For each chunk in manifest:
- Download compressed chunk (~10MB)
- Verify md5_compressed
- Decompress with zlib
- Append to output file
3. Verify final file md5
from galaxy_dl import GalaxyAPI, AuthManager, GalaxyDownloader
# Authenticate
auth = AuthManager()
auth.load_credentials() # Or auth.login_with_code(code)
# Create API and downloader
api = GalaxyAPI(auth)
downloader = GalaxyDownloader(api, max_workers=8) # 8 parallel downloads
# Get manifest
manifest = api.get_manifest_v2(product_id="1234567890", build_id="12345678")
items = api.get_depot_items(manifest.depots[0].manifest)
# Download all items (works for both V1 and V2!)
for item in items:
downloader.download_item(item, "./downloads")# V1 item (flagged by is_v1_blob=True)
v1_item = DepotItem(
path="main.bin",
is_v1_blob=True,
v1_blob_md5="abc123...", # Hash of main.bin
v1_blob_path="main.bin",
total_size_compressed=500_000_000, # 500MB
product_id="1234567890"
)
# Downloads with multi-threaded range requests
downloader.download_item(v1_item, "./downloads")
# Result: ./downloads/main.bin (500MB, verified hash)# V2 item with chunks
v2_item = DepotItem(
path="game.exe",
md5="final_file_hash", # Hash of assembled file
chunks=[chunk1, chunk2, chunk3], # ~10MB each
total_size_compressed=500_000_000,
product_id="1234567890"
)
# Downloads chunks in parallel, assembles, decompresses
downloader.download_item(v2_item, "./downloads")
# Result: ./downloads/game.exe (assembled from chunks)# Download multiple files at once
results = downloader.download_items_parallel(
items=items,
output_dir="./downloads",
max_workers=8, # Total parallel downloads
verify_hash=True
)
# results = {"game.exe": "/path/to/game.exe", "main.bin": "/path/to/main.bin"}- File: main.bin (500MB)
- Workers: 8
- Chunk Size: 10MB
- Speed: ~50-60MB/s (saturates gigabit)
- Time: ~8-10 seconds
- File: game.exe (500MB, 50 chunks)
- Workers: 8
- Chunk Size: ~10MB (from manifest)
- Speed: ~50-60MB/s (saturates gigabit)
- Time: ~8-10 seconds
Both formats now achieve identical performance!
| Method | Description |
|---|---|
__init__(api, max_workers=4) |
Initialize downloader |
download_item(item, output_dir, ...) |
Download single item (auto-detects V1/V2/SFC) |
download_items_parallel(items, ...) |
Download multiple items in parallel |
_download_v1_blob(...) |
Internal: V1 range-based blob download |
_download_v1_file(...) |
Internal: V1 single file extraction from blob |
_download_v1_range(...) |
Internal: Single V1 range request |
_download_v2_item(...) |
Internal: V2 chunk-based download |
_download_v2_item_raw(...) |
Internal: V2 raw mode (no decompression) |
_download_v2_chunk(...) |
Internal: Single V2 chunk download |
_download_v2_chunk_to_file(...) |
Internal: V2 chunk download to v2/store |
_download_range_chunk(task) |
Internal: Execute range download task |
| Field | Type | V1 | V2 | Description |
|---|---|---|---|---|
path |
str | ✅ | ✅ | File path |
md5 |
str | ✅ | ✅ | Hash of extracted/final file |
chunks |
List | ❌ | ✅ | V2 chunk list |
is_v1_blob |
bool | ✅ | ❌ | V1 flag |
v1_blob_md5 |
str | ✅ | ❌ | Hash of main.bin blob |
v1_blob_path |
str | ✅ | ❌ | Path to blob ("main.bin") |
v1_offset |
int | ✅ | ❌ | Offset for extraction |
v1_size |
int | ✅ | ❌ | Size for extraction |
is_small_files_container |
bool | ❌ | ✅ | Is this an SFC file |
is_in_sfc |
bool | ❌ | ✅ | Is file inside an SFC |
sfc_offset |
int | ❌ | ✅ | Offset within SFC |
sfc_size |
int | ❌ | ✅ | Size within SFC |
product_id |
str | ✅ | ✅ | GOG product ID |
is_dependency |
bool | ✅ | ✅ | Is this a dependency file |
# Example range header
range_header = f"bytes={offset}-{offset + size - 1}"
response = session.get(url, headers={'Range': range_header})
# For 500MB file split into 10MB chunks:
# Chunk 0: bytes=0-10485759
# Chunk 1: bytes=10485760-20971519
# Chunk 50: bytes=524288000-524288000 # Last chunk (smaller)# Pre-allocate file
with open(output_path, 'wb') as f:
f.seek(total_size - 1)
f.write(b'\0')
# Write chunks at correct offset (parallel-safe)
with open(output_path, 'r+b') as f:
f.seek(offset)
f.write(chunk_data)import zlib
# V2 chunks are zlib compressed
decompressed = zlib.decompress(chunk_data, ZLIB_WINDOW_SIZE=15)- ✅ Uses range requests for V1 (task_executor.py line 189)
- ✅ Separate v1.py and v2.py managers
- ✅ Multi-threaded downloads
- ✅ Shared memory buffer for chunk assembly
- ✅ C++ implementation
- ✅ Handles both V1 and V2
- ✅ Range-based V1 downloads
- ✅ Chunk verification and assembly
- ✅ Unified Python implementation
- ✅ Multi-threaded V1 range requests (NEW!)
- ✅ Multi-threaded V2 chunks
- ✅ Auto-detection of format
- ✅ Single downloader class for simplicity
from galaxy_dl.downloader import GalaxyDownloader # V2 only
from galaxy_dl.downloader_v1 import GalaxyV1Downloader # V1, single-threaded
v2_dl = GalaxyDownloader(api)
v1_dl = GalaxyV1Downloader(api)
v2_dl.download_item(v2_item, "./downloads") # Fast
v1_dl.download_v1_blob(v1_item, "./downloads") # SLOW! Single-threadedfrom galaxy_dl.downloader import GalaxyDownloader # Both V1 and V2
dl = GalaxyDownloader(api, max_workers=8)
# Both are now multi-threaded!
dl.download_item(v1_item, "./downloads") # Fast! Multi-threaded range
dl.download_item(v2_item, "./downloads") # Fast! Multi-threaded chunks- Use unified downloader: One
GalaxyDownloaderinstance for all downloads - Set max_workers: 4-8 workers optimal for most connections
- Verify hashes: Always use
verify_hash=Truein production - Reuse sessions: Downloader reuses HTTP session for efficiency
- Handle both formats: Don't assume manifest version
-
heroic-gogdl: https://github.com/Heroic-Games-Launcher/heroic-gogdl
gogdl/dl/workers/task_executor.py- Range request implementationgogdl/dl/managers/v1.py- V1 managergogdl/dl/managers/v2.py- V2 manager
-
lgogdownloader: https://github.com/Sude-/lgogdownloader
src/downloader.cpp- C++ download logic- Handles both V1/V2 manifests
-
GOG Galaxy CDN:
- V1:
https://cdn.gog.com/.../main.bin(with Range headers) - V2:
https://cdn.gog.com/{galaxy_path}(individual chunks)
- V1: