feat: add packed archive support with brotli compression, add configuration and more

Add support for serving assets from packed binary archives with brotli compression.

New features:
- `packer_brotli.py`: Pack vcsky/vcbr folders into single .bin archive with brotli compression (quality 11)
- `downloader_brotli.py`: Stream unpack archives during download
- `--packed` flag: Serve files directly from archive (supports local files and URLs)
- `--unpacked` flag: Extract archive to `unpacked/{hash}/` and serve from there
- `--vcsky_local` / `--vcbr_local`: Now accept optional custom paths
- MD5 hash detection: Pass existing folder hash directly to `--unpacked`
- `--pack` for packing

Archive format:
- ULEB128 length encoding for compact size
- Folder/file deduplication (identical content stored once)
- Parallel compression with ProcessPoolExecutor

and much more, like ?configurable=1

and auto language detection fix, ...
This commit is contained in:
Alexey Olendor
2025-12-27 10:41:45 +03:00
parent 0e1d99fc7b
commit 19edc9df14
12 changed files with 3360 additions and 129 deletions
+2
View File
@@ -7,3 +7,5 @@ __pycache__/
vcbr/
vcsky/
saves/
unpacked/
*.bin
+62 -22
View File
@@ -21,10 +21,12 @@ Web-based port of GTA: Vice City running in browser via WebAssembly.
2. **Configure Assets** (Optional):
By default, the project uses the **DOS Zone CDN**. For local hosting, download and place assets in [(see structure)](#project-structure):
* **Resources:** `vcsky/fetched/` (or `fetched-ru/`) — `data`, `audio`, `anim`, `models` folders.
* **Binaries:** `vcbr/` — `.wasm.br` and `.data.br` files for your chosen language.
4. **Launch the Application**:
By default, the project uses **DOS Zone CDN** — no local assets needed. For offline hosting you can use:
* **Packed archive** (`--packed` or `--unpacked`) — single `.bin` file with all assets
* **Local folders** (`--vcsky_local`, `--vcbr_local`) — unpacked asset directories
* **Cache mode** (`--vcsky_cache`, `--vcbr_cache`) — download from CDN once, serve locally after
3. **Launch the Application**:
Choose one of the setup methods below:
* **Docker** (Recommended for most users) — fast and isolated.
* **PHP** — Simply upload the folder to your web server (FTP/Hosting).
@@ -36,7 +38,7 @@ Web-based port of GTA: Vice City running in browser via WebAssembly.
The easiest way to get started is using Docker Compose:
```bash
VCSKY_CACHE=1 VCBR_CACHE=1 docker compose up -d --build
PACKED=https://folder.morgen.monster/revcdos.bin docker compose up -d --build
```
To configure server options via environment variables:
@@ -54,12 +56,15 @@ IN_PORT=3000 AUTH_LOGIN=admin AUTH_PASSWORD=secret CUSTOM_SAVES=1 docker compose
| `AUTH_LOGIN` | HTTP Basic Auth username |
| `AUTH_PASSWORD` | HTTP Basic Auth password |
| `CUSTOM_SAVES` | Enable local saves (set to `1`) |
| `VCSKY_LOCAL` | Serve vcsky from local directory (set to `1`) |
| `VCBR_LOCAL` | Serve vcbr from local directory (set to `1`) |
| `VCSKY_LOCAL` | Serve vcsky from local directory (set to `1`, or path like `/data/vcsky`) |
| `VCBR_LOCAL` | Serve vcbr from local directory (set to `1`, or path like `/data/vcbr`) |
| `VCSKY_URL` | Custom vcsky proxy URL |
| `VCBR_URL` | Custom vcbr proxy URL |
| `VCSKY_CACHE` | Cache vcsky files locally while proxying (set to `1`) |
| `VCBR_CACHE` | Cache vcbr files locally while proxying (set to `1`) |
| `PACKED` | Serve from packed archive (filename or URL, e.g., `revcdos.bin`) |
| `UNPACKED` | Unpack archive to local folders (filename or URL, auto-sets vcsky/vcbr paths) |
| `PACK` | Pack a folder and serve from resulting archive (folder path or MD5 hash) |
### Option 2: Local Installation
@@ -70,7 +75,7 @@ pip install -r requirements.txt
2. Start the server:
```bash
python server.py --vcsky_cache --vcbr_cache
python server.py --packed https://folder.morgen.monster/revcdos.bin
```
Server starts at `http://localhost:8000`
@@ -87,12 +92,15 @@ By default the `index.php` and `.htaccess` will get the job done.
| `--custom_saves` | flag | disabled | Enable local save files (saves router) |
| `--login` | string | none | HTTP Basic Auth username |
| `--password` | string | none | HTTP Basic Auth password |
| `--vcsky_local` | flag | disabled | Serve vcsky from local `vcsky/` directory |
| `--vcbr_local` | flag | disabled | Serve vcbr from local `vcbr/` directory |
| `--vcsky_local` | string/flag | disabled | Serve vcsky from local directory. Use flag for `vcsky/` or specify path |
| `--vcbr_local` | string/flag | disabled | Serve vcbr from local directory. Use flag for `vcbr/` or specify path |
| `--vcsky_url` | string | `https://cdn.dos.zone/vcsky/` | Custom vcsky proxy URL |
| `--vcbr_url` | string | `https://br.cdn.dos.zone/vcsky/` | Custom vcbr proxy URL |
| `--vcsky_cache` | flag | disabled | Cache vcsky files locally while proxying |
| `--vcbr_cache` | flag | disabled | Cache vcbr files locally while proxying |
| `--packed` | string | disabled | Serve from packed archive file. Accepts file path or URL |
| `--unpacked` | string | disabled | Unpack archive to `unpacked/{hash}/` and serve from there. Accepts file path or URL |
| `--pack` | string | disabled | Pack a folder and serve from resulting `{hash}.bin` archive. Accepts folder path or MD5 hash |
**Examples:**
```bash
@@ -105,25 +113,45 @@ python server.py --custom_saves
# Enable HTTP Basic Authentication
python server.py --login admin --password secret123
# Use local vcsky and vcbr files (fully offline mode)
# Use local vcsky and vcbr files
python server.py --vcsky_local --vcbr_local
# Use custom proxy URLs
python server.py --vcsky_url https://my-cdn.example.com/vcsky/ --vcbr_url https://my-cdn.example.com/vcbr/
# Cache files locally while proxying (hybrid mode) (recommended)
python server.py --vcsky_cache --vcbr_cache
# All options combined
python server.py --port 3000 --custom_saves --login admin --password secret123 --vcsky_local --vcbr_local
# Serve from packed archive (local file)
python server.py --packed revcdos.bin
# Serve from packed archive (download from URL if not present)
python server.py --packed https://example.com/revcdos.bin
# Unpack archive and serve from unpacked files
python server.py --unpacked revcdos.bin
# Stream-unpack from URL (downloads and unpacks simultaneously)
python server.py --unpacked https://example.com/revcdos.bin
# Pack a folder and serve from the resulting archive
python server.py --pack /path/to/assets # Creates {folder_hash}.bin
# Pack from existing unpacked folder by MD5 hash
python server.py --pack a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 # Uses unpacked/{hash}/
```
> **Note:** HTTP Basic Auth is only enabled when both `--login` and `--password` are provided.
> **Note:** By default, vcsky and vcbr are proxied from DOS Zone CDN. Use `--vcsky_local` and `--vcbr_local` flags to serve files from local directories instead.
> **Note:** By default, vcsky and vcbr are proxied from DOS Zone CDN. Use `--vcsky_local` and `--vcbr_local` flags to serve files from local directories instead. You can optionally specify a custom path.
> **Note:** Use `--vcsky_cache` and `--vcbr_cache` to cache proxied files locally. Files are downloaded once and served from local storage on subsequent requests.
> **Note:** `--packed` serves files directly from a compressed archive without unpacking (faster and more compressed). `--unpacked` extracts the archive once and serves from local files (if you want edit assets).
> **Note:** When using URL with `--unpacked`, the archive is streamed and unpacked simultaneously during download using `downloader_brotli.py`.
> **Note:** You can pass a raw MD5 hash (32 hex characters) to `--unpacked` to use an existing unpacked folder without needing the original archive. Example: if you have `unpacked/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6/`, you can start the server with `--unpacked a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6`.
> **Note:** `--pack` creates a packed archive from a folder containing subfolders (like `vcsky/` and `vcbr/`). Each subfolder is packed sequentially: the first creates the archive, subsequent ones are appended. After packing, the server automatically uses the created archive via `--packed` mode.
## URL Parameters
| Parameter | Values | Description |
@@ -133,23 +161,35 @@ python server.py --port 3000 --custom_saves --login admin --password secret123 -
| `request_original_game` | `1` | Request original game files before play |
| `fullscreen` | `0` | Disable auto-fullscreen |
| `max_fps` | `1-240` | Limit frame rate (e.g., `60` for 60 FPS) |
| `configurable` | `1` | Show configuration UI before play button |
**Examples:**
- `http://localhost:8000/?lang=ru` — Russian version
- `http://localhost:8000/?lang=en&cheats=1` — English + cheats
- `http://localhost:8000/?configurable=1` — Show settings UI before play
## Project Structure
```
├── server.py # FastAPI proxy server
├── index.php # php proxy server
├── .htaccess # apache config for php
├── index.php # PHP proxy server
├── .htaccess # Apache config for PHP
├── requirements.txt # Python dependencies
├── packer_brotli.py # Archive packer with brotli compression
├── downloader_brotli.py # Stream unpacker for packed archives
├── revcdos.bin # Packed archive (optional, created by packer_brotli.py)
├── additions/ # Server extensions
│ ├── auth.py # HTTP Basic Auth middleware
│ ├── cache.py # Proxy caching and brotli decompression
│ ├── packed.py # Packed archive serving module
│ └── saves.py # Local saves router
├── utils/ # Utility modules
│ └── packer_brotli.py # Packer module (imported by server)
├── unpacked/ # Auto-created by --unpacked flag
│ └── {md5_hash}/ # Unpacked files organized by source hash
│ ├── vcsky/ # Decompressed game assets
│ └── vcbr/ # Brotli-compressed binaries
├── dist/ # Game client files
│ ├── index.html # Main page
│ ├── game.js # Game loader
@@ -180,13 +220,13 @@ python server.py --port 3000 --custom_saves --login admin --password secret123 -
│ ├── vc-sky-en-v6.wasm.br
│ ├── vc-sky-ru-v6.data.br
│ └── vc-sky-ru-v6.wasm.br
└── vcsky/ # Decompressed assets (optional)
├── fetched/ # English version files
└── vcsky/ # Decompressed assets (optional)
├── fetched/ # English version files
│ ├── data/
│ ├── audio/
│ ├── models/
│ └── anim/
└── fetched-ru/ # Russian version files
└── fetched-ru/ # Russian version files
├── data/
├── audio/
└── ...
+411
View File
@@ -0,0 +1,411 @@
"""
Module for serving files from a PackedArchive.
Provides similar interface to cache.py but reads from packed .bin archives.
Supports:
- Serving files from vcsky/ and vcbr/ paths inside the archive
- Brotli compression passthrough when client supports it (Accept-Encoding: br)
- On-the-fly decompression when client doesn't support brotli
- Proper handling of .br files (stored without additional compression)
- Auto-download from URL if archive file is not present locally
"""
import os
import sys
from typing import Optional
from urllib.parse import urlparse
import httpx
import brotli
from fastapi import Request
from fastapi.responses import Response, StreamingResponse
# Import PackedArchive from utils
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'utils'))
from utils.packer_brotli import PackedArchive
# Global archive instance (initialized by init_packed_archive)
_archive: Optional[PackedArchive] = None
def _is_url(path: str) -> bool:
"""Check if the path is a URL."""
return path.startswith("http://") or path.startswith("https://")
def _get_filename_from_url(url: str) -> str:
"""Extract filename from URL."""
parsed = urlparse(url)
path = parsed.path
filename = os.path.basename(path)
if not filename:
filename = "packed.bin"
return filename
async def _download_file(url: str, dest_path: str) -> bool:
"""
Download a file from URL to destination path.
Args:
url: URL to download from
dest_path: Local path to save the file
Returns:
True if download succeeded, False otherwise
"""
print(f"Downloading archive from {url}...")
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0), follow_redirects=True) as client:
async with client.stream('GET', url) as response:
response.raise_for_status()
content_length = response.headers.get('content-length')
total_size = int(content_length) if content_length else 0
downloaded = 0
with open(dest_path, 'wb') as f:
async for chunk in response.aiter_bytes(65536):
f.write(chunk)
downloaded += len(chunk)
if total_size > 0:
percent = (downloaded / total_size) * 100
print(f"\r Downloaded: {downloaded / 1024 / 1024:.1f} MB ({percent:.1f}%)", end="", flush=True)
else:
print(f"\r Downloaded: {downloaded / 1024 / 1024:.1f} MB", end="", flush=True)
print() # New line after download complete
print(f" Saved to: {dest_path}")
return True
except httpx.HTTPStatusError as e:
print(f"Failed to download: HTTP {e.response.status_code}")
return False
except Exception as e:
print(f"Error downloading file: {e}")
return False
async def resolve_packed_source(source: str) -> Optional[str]:
"""
Resolve packed archive source to local file path.
If source is a URL:
- Extract filename from URL
- Check if file exists locally and has size > 0
- If not, download it from URL
- Return local file path
If source is a local path:
- Return it as-is
Args:
source: URL or local file path
Returns:
Local file path, or None if download failed
"""
if not _is_url(source):
# Local file path
return source
# It's a URL - extract filename and check if we have it locally
filename = _get_filename_from_url(source)
local_path = filename # Save in current directory
# Check if file exists and has size > 0
if os.path.isfile(local_path) and os.path.getsize(local_path) > 0:
print(f"Using existing archive: {local_path} ({os.path.getsize(local_path)} bytes)")
return local_path
# File doesn't exist or is empty - download it
if await _download_file(source, local_path):
return local_path
return None
async def init_packed_archive(source: str) -> Optional[PackedArchive]:
"""
Initialize the packed archive.
Must be called before using get_packed_file().
Supports both local file paths and URLs.
If a URL is provided, the file will be downloaded if not present locally.
Args:
source: Path to the .bin archive file or URL to download from
Returns:
Initialized PackedArchive instance, or None if failed
"""
global _archive
# Resolve source to local path (download if needed)
archive_path = await resolve_packed_source(source)
if archive_path is None:
print(f"Failed to resolve packed archive source: {source}")
return None
if not os.path.isfile(archive_path):
print(f"Archive file not found: {archive_path}")
return None
_archive = PackedArchive(archive_path)
await _archive.init()
print(f"Loaded packed archive: {archive_path}")
print(f" Folders: {len(_archive.list_folders())}")
print(f" Files: {len(_archive.list_files())}")
return _archive
def get_archive() -> Optional[PackedArchive]:
"""Get the global archive instance."""
return _archive
def is_initialized() -> bool:
"""Check if the archive is initialized."""
return _archive is not None and _archive._initialized
def _client_accepts_brotli(request: Request) -> bool:
"""Check if client accepts brotli encoding."""
accept_encoding = request.headers.get("accept-encoding", "")
return "br" in accept_encoding.lower()
def _get_response_headers(use_brotli: bool, media_type: str) -> dict:
"""Get response headers, optionally with brotli encoding."""
headers = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
"Content-Type": media_type
}
if use_brotli:
headers["Content-Encoding"] = "br"
return headers
def _is_br_file(path: str) -> bool:
"""Check if the file is a .br (pre-compressed brotli) file."""
return path.lower().endswith(".br")
def _get_media_type(path: str) -> str:
"""
Get appropriate media type based on file extension.
For .br files, returns the media type of the underlying content.
"""
lower_path = path.lower()
# Handle .br files - get media type of what's inside
if lower_path.endswith(".wasm.br"):
return "application/wasm"
if lower_path.endswith(".js.br"):
return "application/javascript"
if lower_path.endswith(".json.br"):
return "application/json"
if lower_path.endswith(".html.br"):
return "text/html"
if lower_path.endswith(".css.br"):
return "text/css"
if lower_path.endswith(".br"):
# Generic .br file - use octet-stream
return "application/octet-stream"
# Non-.br files
if lower_path.endswith(".wasm"):
return "application/wasm"
if lower_path.endswith(".js"):
return "application/javascript"
if lower_path.endswith(".json"):
return "application/json"
if lower_path.endswith(".html"):
return "text/html"
if lower_path.endswith(".css"):
return "text/css"
if lower_path.endswith(".png"):
return "image/png"
if lower_path.endswith(".jpg") or lower_path.endswith(".jpeg"):
return "image/jpeg"
if lower_path.endswith(".gif"):
return "image/gif"
if lower_path.endswith(".svg"):
return "image/svg+xml"
if lower_path.endswith(".mp3"):
return "audio/mpeg"
if lower_path.endswith(".wav"):
return "audio/wav"
if lower_path.endswith(".ogg"):
return "audio/ogg"
return "application/octet-stream"
async def get_packed_file(path: str, request: Request) -> Optional[Response]:
"""
Get a file from the packed archive.
How .br files work:
- .br files are stored in the archive WITHOUT additional brotli compression
- archive.open(path) returns the raw .br file content (already brotli-compressed)
- If client accepts br: send .br data with Content-Encoding: br
- If client doesn't accept br: decompress .br data and send plain
How regular files work:
- Regular files are stored with brotli compression in the archive
- If client accepts br: keep_brotli=True returns compressed data, send with Content-Encoding: br
- If client doesn't accept br: keep_brotli=False decompresses, send plain
Args:
path: Path to the file inside the archive (e.g., "vcsky/fetched/model.txd")
request: FastAPI request object to check Accept-Encoding header
Returns:
Response with file data, or None if file not found or archive not initialized
"""
if not is_initialized():
return None
# Check if file exists in archive
if not _archive.exists(path):
return None
# Check if client accepts brotli
client_accepts_br = _client_accepts_brotli(request)
# Check if file is a .br file
is_br_file = _is_br_file(path)
# Get media type based on file extension
media_type = _get_media_type(path)
try:
if is_br_file:
# .br files are stored as-is (no archive compression)
# archive.open() returns the raw .br content
async with _archive.open(path, keep_brotli=False) as f:
br_data = f.read()
if client_accepts_br:
# Send the .br data with Content-Encoding: br
headers = _get_response_headers(use_brotli=True, media_type=media_type)
return Response(content=br_data, headers=headers)
else:
# Decompress .br for client
decompressed_data = brotli.decompress(br_data)
headers = _get_response_headers(use_brotli=False, media_type=media_type)
return Response(content=decompressed_data, headers=headers)
else:
# Regular files: use archive's brotli compression
if client_accepts_br:
async with _archive.open(path, keep_brotli=True) as f:
data = f.read()
headers = _get_response_headers(use_brotli=True, media_type=media_type)
else:
async with _archive.open(path, keep_brotli=False) as f:
data = f.read()
headers = _get_response_headers(use_brotli=False, media_type=media_type)
return Response(content=data, headers=headers)
except FileNotFoundError:
return None
except Exception as e:
print(f"Error reading file from archive: {path} - {e}")
return None
async def get_packed_file_streaming(path: str, request: Request, chunk_size: int = 65536) -> Optional[StreamingResponse]:
"""
Get a file from the packed archive as a streaming response.
Args:
path: Path to the file inside the archive
request: FastAPI request object to check Accept-Encoding header
chunk_size: Size of chunks for streaming (default: 64KB)
Returns:
StreamingResponse with file data, or None if file not found
"""
if not is_initialized():
return None
if not _archive.exists(path):
return None
client_accepts_br = _client_accepts_brotli(request)
is_br = _is_br_file(path)
media_type = _get_media_type(path)
async def generate():
try:
if is_br:
# .br file: stored as-is, open returns raw .br data
async with _archive.open(path, keep_brotli=False) as f:
br_data = f.data
if client_accepts_br:
# Send .br data directly
for i in range(0, len(br_data), chunk_size):
yield br_data[i:i + chunk_size]
else:
# Decompress for client
decompressed_data = brotli.decompress(br_data)
for i in range(0, len(decompressed_data), chunk_size):
yield decompressed_data[i:i + chunk_size]
else:
# Regular file
async with _archive.open(path, keep_brotli=client_accepts_br) as f:
data = f.data
for i in range(0, len(data), chunk_size):
yield data[i:i + chunk_size]
except Exception as e:
print(f"Error streaming file from archive: {path} - {e}")
headers = _get_response_headers(use_brotli=client_accepts_br, media_type=media_type)
return StreamingResponse(generate(), headers=headers)
def file_exists(path: str) -> bool:
"""
Check if a file exists in the packed archive.
Args:
path: Path to the file inside the archive
Returns:
True if file exists, False otherwise
"""
if not is_initialized():
return False
return _archive.exists(path)
def list_files(folder: Optional[str] = None) -> list:
"""
List files in the archive.
Args:
folder: Optional folder path to filter by
Returns:
List of file paths
"""
if not is_initialized():
return []
return _archive.list_files(folder)
def list_folders() -> list:
"""
List all folders in the archive.
Returns:
List of folder paths
"""
if not is_initialized():
return []
return _archive.list_folders()
+52 -4
View File
@@ -50,7 +50,7 @@
"# Start server\n",
"print(\"🚀 Starting server...\")\n",
"server = subprocess.Popen(\n",
" [sys.executable, \"server.py\", \"--vcsky_cache\", \"--vcbr_cache\"],\n",
" [sys.executable, \"server.py\", \"--packed\", \"https://folder.morgen.monster/revcdos.bin\"],\n",
" stdout=subprocess.DEVNULL,\n",
" stderr=subprocess.DEVNULL\n",
")\n",
@@ -82,11 +82,11 @@
"if tunnel_url:\n",
" # Display interactive button that copies password and opens URL\n",
" html = f'''\n",
" <div style=\"font-family: 'Google Sans', Roboto, Arial, sans-serif; padding: 24px; background-color: #202124; border: 1px solid #3c4043; border-radius: 12px; text-align: center; max-width: 500px; margin: 10px auto; box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);\">\n",
" <div style=\"font-family: 'Google Sans', Roboto, Arial, sans-serif; padding: 24px; background-color: #202124; border: 1px solid #3c4043; border-radius: 12px; text-align: center; max-width: 600px; margin: 10px auto; box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);\">\n",
" <div style=\"color: #e8eaed; font-size: 22px; font-weight: 500; margin-bottom: 8px;\">Server is ready</div>\n",
" <div style=\"color: #9aa0a6; font-size: 14px; margin-bottom: 24px;\">The tunnel is active. Configure options and launch the game.</div>\n",
" \n",
" <div style=\"margin-bottom: 24px; display: flex; justify-content: center; gap: 24px; align-items: center;\">\n",
" <div style=\"margin-bottom: 24px; display: flex; justify-content: center; gap: 24px; align-items: flex-start; flex-wrap: wrap;\">\n",
" <div style=\"text-align: left;\">\n",
" <label style=\"color: #9aa0a6; font-size: 12px; display: block; margin-bottom: 6px; font-weight: 500;\">Language</label>\n",
" <div style=\"position: relative;\">\n",
@@ -109,6 +109,44 @@
" </div>\n",
" </div>\n",
" <div style=\"text-align: left;\">\n",
" <label style=\"color: #9aa0a6; font-size: 12px; display: block; margin-bottom: 6px; font-weight: 500;\">Max FPS</label>\n",
" <div style=\"position: relative;\">\n",
" <select id=\"max_fps\" style=\"\n",
" appearance: none;\n",
" background: #202124;\n",
" color: #e8eaed;\n",
" border: 1px solid #5f6368;\n",
" border-radius: 4px;\n",
" padding: 8px 32px 8px 12px;\n",
" font-size: 14px;\n",
" outline: none;\n",
" cursor: pointer;\n",
" font-family: inherit;\n",
" \" onmouseover=\"this.style.borderColor='#dadce0'\" onmouseout=\"this.style.borderColor='#5f6368'\" onchange=\"document.getElementById('custom_fps_container').style.display = this.value === 'custom' ? 'block' : 'none'\">\n",
" <option value=\"\">Unlocked</option>\n",
" <option value=\"30\">30 FPS</option>\n",
" <option value=\"60\">60 FPS</option>\n",
" <option value=\"120\">120 FPS</option>\n",
" <option value=\"240\">240 FPS</option>\n",
" <option value=\"custom\">Custom</option>\n",
" </select>\n",
" <div style=\"position: absolute; right: 10px; top: 50%; transform: translateY(-50%); pointer-events: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid #9aa0a6;\"></div>\n",
" </div>\n",
" <div id=\"custom_fps_container\" style=\"display: none; margin-top: 8px;\">\n",
" <input type=\"number\" id=\"custom_fps\" min=\"1\" max=\"240\" placeholder=\"1-240\" style=\"\n",
" background: #202124;\n",
" color: #e8eaed;\n",
" border: 1px solid #5f6368;\n",
" border-radius: 4px;\n",
" padding: 8px 12px;\n",
" font-size: 14px;\n",
" outline: none;\n",
" width: 80px;\n",
" font-family: inherit;\n",
" \" onmouseover=\"this.style.borderColor='#dadce0'\" onmouseout=\"this.style.borderColor='#5f6368'\">\n",
" </div>\n",
" </div>\n",
" <div style=\"text-align: left;\">\n",
" <label style=\"color: #9aa0a6; font-size: 12px; display: block; margin-bottom: 6px; font-weight: 500;\">Options</label>\n",
" <label style=\"display: flex; align-items: center; cursor: pointer; color: #e8eaed; font-size: 14px;\">\n",
" <input type=\"checkbox\" id=\"cheats\" style=\"\n",
@@ -192,7 +230,17 @@
" const cheats = document.getElementById('cheats').checked ? '&cheats=1' : '';\n",
" const reqOrig = document.getElementById('req_orig').checked ? '&request_original_game=1' : '';\n",
" const fullscreen = document.getElementById('fullscreen').checked ? '' : '&fullscreen=0';\n",
" const finalUrl = `{tunnel_url}/?lang=${{lang}}${{cheats}}${{reqOrig}}${{fullscreen}}`;\n",
" const maxFpsSelect = document.getElementById('max_fps').value;\n",
" let maxFps = '';\n",
" if (maxFpsSelect === 'custom') {{\n",
" const customVal = document.getElementById('custom_fps').value;\n",
" if (customVal && parseInt(customVal) >= 1 && parseInt(customVal) <= 240) {{\n",
" maxFps = '&max_fps=' + customVal;\n",
" }}\n",
" }} else if (maxFpsSelect) {{\n",
" maxFps = '&max_fps=' + maxFpsSelect;\n",
" }}\n",
" const finalUrl = `{tunnel_url}/?lang=${{lang}}${{cheats}}${{reqOrig}}${{fullscreen}}${{maxFps}}`;\n",
" \n",
" navigator.clipboard.writeText(\"{password}\").then(function() {{\n",
" document.getElementById(\"status\").innerHTML = \"✓ Password copied! Opening game...\";\n",
+194 -81
View File
@@ -11,17 +11,18 @@ const params = new URLSearchParams(window.location.search);
const replaceFetch = (str) => str.replace("https://cdn.dos.zone/vcsky/", "/vcsky/")
const replaceBR = "/vcbr/"
const autoFullScreen = params.get('fullscreen') !== "0";
// Configurable mode - show settings UI before play
const configurableMode = params.get('configurable') === "1";
// F3 for activation (cheat-menu)
const cheatsEnabled = params.get('cheats') === "1"
// Settings that can be configured via URL or UI
let autoFullScreen = params.get('fullscreen') !== "0";
let cheatsEnabled = params.get('cheats') === "1";
let maxFPS = parseInt(params.get('max_fps')) || 0;
// full game access
if (params.get('request_original_game') !== "1")
localStorage.setItem('vcsky.haveOriginalGame', 'true');
const maxFPS = parseInt(params.get('max_fps')) || 0;
const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
let isTouch = isMobile && window.matchMedia('(pointer: coarse)').matches;
@@ -30,88 +31,155 @@ document.body.dataset.isTouch = isTouch ? 1 : 0;
const dataSize = 130 * 1024 * 1024;
const textDecoder = new TextDecoder();
let haveOriginalGame = false;
(function () {
const translations = {
en: {
clickToPlayDemo: "Click to play demo",
clickToPlayFull: "Click to play",
invalidKey: "invalid key",
checking: "checking...",
cloudSaves: "Cloud saves:",
enabled: "enabled",
disabled: "disabled",
playDemoText: "You can play the DEMO version, or provide the original game files to play the full version.",
disclaimer: "DISCLAIMER:",
disclaimerSources: "This game is based on an open source version of GTA: Vice City. It is not a commercial release and is not affiliated with Rockstar Games.",
disclaimerCheckbox: "I own the original game",
disclaimerPrompt: "You need to provide a file from the original game to confirm ownership of the original game.",
cantContinuePlaying: "You can't continue playing in DEMO version. Please provide the original game files to continue playing.",
demoAlert: "The demo version is intended only for familiarizing yourself with the game technology. All features are available, but you won’t be able to progress through the game’s storyline. Please provide the original game files to launch the full version.",
downloading: "Downloading",
enterKey: "enter your key",
clickToContinue: "Click to continue...",
enterJsDosKey: "Enter js-dos key (5 len)",
portBy: "HTML5 port by:",
ruTranslate: "",
demoOffDisclaimer: "Due to the unexpectedly high popularity of the project, resulting in significant traffic costs, and in order to avoid any risk of the project being shut down due to rights holder claims, we have disabled the demo version. You can still run the full version by providing the original game resources.",
},
ru: {
clickToPlayDemo: "Играть в демо версию",
clickToPlayFull: "Играть",
invalidKey: "неверный ключ",
checking: "проверка...",
cloudSaves: "Облачные сохранения:",
enabled: "включены",
disabled: "выключены",
playDemoText: "Вы можете играть в демо версию, или предоставить оригинальные файлы игры для полной версии.",
disclaimer: "ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ:",
disclaimerSources: "Эта игра основана на открытой версии GTA: Vice City. Она не является коммерческим изданием и не связана с Rockstar Games.",
disclaimerCheckbox: "Я владею оригинальной игрой",
disclaimerPrompt: "Вам потребуется приложить какой-либо файл из оригинальной игры для подтверждения владения оригинальной игрой.",
cantContinuePlaying: "Вы не можете продолжить игру в демо версии. Пожалуйста, предоставьте оригинальные файлы игры для продолжения игры.",
demoAlert: "Демо версия предназначена только для ознакомления с технологией игры. Все функции доступны, но вы не сможете продолжить игру по сюжету. Пожалуйста, предоставьте оригинальные файлы игры для запуска полной версии.",
downloading: "Загрузка",
enterKey: "введите ваш ключ",
clickToContinue: "Нажмите для продолжения...",
enterJsDosKey: "Введите ключ js-dos (5 букв)",
portBy: "Авторы HTML5 порта:",
ruTranslate: `
const translations = {
en: {
clickToPlayDemo: "Click to play demo",
clickToPlayFull: "Click to play",
invalidKey: "invalid key",
checking: "checking...",
cloudSaves: "Cloud saves:",
enabled: "enabled",
disabled: "disabled",
playDemoText: "You can play the DEMO version, or provide the original game files to play the full version.",
disclaimer: "DISCLAIMER:",
disclaimerSources: "This game is based on an open source version of GTA: Vice City. It is not a commercial release and is not affiliated with Rockstar Games.",
disclaimerCheckbox: "I own the original game",
disclaimerPrompt: "You need to provide a file from the original game to confirm ownership of the original game.",
cantContinuePlaying: "You can't continue playing in DEMO version. Please provide the original game files to continue playing.",
demoAlert: "The demo version is intended only for familiarizing yourself with the game technology. All features are available, but you won't be able to progress through the game's storyline. Please provide the original game files to launch the full version.",
downloading: "Downloading",
enterKey: "enter your key",
clickToContinue: "Click to continue...",
enterJsDosKey: "Enter js-dos key (5 len)",
portBy: "HTML5 port by:",
ruTranslate: "",
demoOffDisclaimer: "Due to the unexpectedly high popularity of the project, resulting in significant traffic costs, and in order to avoid any risk of the project being shut down due to rights holder claims, we have disabled the demo version. You can still run the full version by providing the original game resources.",
configLanguage: "Language:",
configCheats: "Cheats (F3)",
configFullscreen: "Fullscreen",
configMaxFps: "Max FPS:",
configUnlimited: "(0 = unlimited)",
},
ru: {
clickToPlayDemo: "Играть в демо версию",
clickToPlayFull: "Играть",
invalidKey: "неверный ключ",
checking: "проверка...",
cloudSaves: "Облачные сохранения:",
enabled: "включены",
disabled: "выключены",
playDemoText: "Вы можете играть в демо версию, или предоставить оригинальные файлы игры для полной версии.",
disclaimer: "ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ:",
disclaimerSources: "Эта игра основана на открытой версии GTA: Vice City. Она не является коммерческим изданием и не связана с Rockstar Games.",
disclaimerCheckbox: "Я владею оригинальной игрой",
disclaimerPrompt: "Вам потребуется приложить какой-либо файл из оригинальной игры для подтверждения владения оригинальной игрой.",
cantContinuePlaying: "Вы не можете продолжить игру в демо версии. Пожалуйста, предоставьте оригинальные файлы игры для продолжения игры.",
demoAlert: "Демо версия предназначена только для ознакомления с технологией игры. Все функции доступны, но вы не сможете продолжить игру по сюжету. Пожалуйста, предоставьте оригинальные файлы игры для запуска полной версии.",
downloading: "Загрузка",
enterKey: "введите ваш ключ",
clickToContinue: "Нажмите для продолжения...",
enterJsDosKey: "Введите ключ js-dos (5 букв)",
portBy: "Авторы HTML5 порта:",
ruTranslate: `
<div class="translated-by">
<span>Переведено на русский студией</span>
<a href="https://www.gamesvoice.ru/" target="_blank">GamesVoice</a>
</div>
`,
demoOffDisclaimer: "В связи с неожиданно высокой популярностью проекта, как следствие — значительными расходами на трафик, а также во избежание рисков закрытия проекта из-за претензий правообладателей, мы отключили возможность запуска демо-версии. При этом вы по-прежнему можете запустить полную версию, предоставив оригинальные ресурсы.",
},
};
demoOffDisclaimer: "В связи с неожиданно высокой популярностью проекта, как следствие — значительными расходами на трафик, а также во избежание рисков закрытия проекта из-за претензий правообладателей, мы отключили возможность запуска демо-версии. При этом вы по-прежнему можете запустить полную версию, предоставив оригинальные ресурсы.",
configLanguage: "Язык:",
configCheats: "Читы (F3)",
configFullscreen: "Полный экран",
configMaxFps: "Макс. FPS:",
configUnlimited: "(0 = без ограничений)",
},
};
let currentLanguage = navigator.language.split("-")[0] === "ru" ? "ru" : "en";
if (params.get("lang") === "ru") {
currentLanguage = "ru";
// }
// if (params.get("lang") === "en") {
}else{
currentLanguage = "en";
}
window.t = function (key) {
return translations[currentLanguage][key];
}
})();
if (params.get('lang') === 'ru') {
data_content = `${replaceBR}vc-sky-ru-v6.data.br`;
wasm_content = `${replaceBR}vc-sky-ru-v6.wasm.br`;
// } else if (params.get('lang') === 'ru') {
// data_content = `${replaceBR}vc-sky-ru-v6.data.br`;
// wasm_content = `${replaceBR}vc-sky-ru-v6.wasm.br`;
} else {
data_content = `${replaceBR}vc-sky-en-v6.data.br`;
wasm_content = `${replaceBR}vc-sky-en-v6.wasm.br`;
// data_content = "index.old.data";
var currentLanguage = navigator.language.split("-")[0] === "ru" ? "ru" : "en";
if (params.get("lang") === "ru") {
currentLanguage = "ru";
} else if (params.get("lang") === "en") {
currentLanguage = "en";
}
// data_content = `${replaceBR}vc-sky-ru-v6.data.br`;
// wasm_content = `${replaceBR}vc-sky-ru-v6.wasm.br`;
window.t = function (key) {
return translations[currentLanguage][key];
}
// Function to update all translated texts on the page
function updateAllTranslations() {
const keyInput = document.querySelector('.jsdos-key-input');
if (keyInput) keyInput.setAttribute('placeholder', t("enterJsDosKey"));
const clickToPlayButton = document.getElementById('click-to-play-button');
if (clickToPlayButton) {
clickToPlayButton.textContent = haveOriginalGame ? t('clickToPlayFull') : t('clickToPlayDemo');
}
const demoOffDisclaimer = document.getElementById('demo-off-disclaimer');
if (demoOffDisclaimer) {
demoOffDisclaimer.textContent = haveOriginalGame ? "" : "* " + t('demoOffDisclaimer');
}
const cloudSavesLink = document.getElementById('cloud-saves-link');
if (cloudSavesLink) cloudSavesLink.textContent = t('cloudSaves');
const cloudSavesStatus = document.getElementById('cloud-saves-status');
if (cloudSavesStatus) cloudSavesStatus.textContent = t('enterKey');
const playDemoText = document.getElementById('play-demo-text');
if (playDemoText) playDemoText.textContent = t('playDemoText');
const disclaimerText = document.getElementById('disclaimer-text');
if (disclaimerText) disclaimerText.textContent = t('disclaimer');
const disclaimerSources = document.getElementById('disclaimer-sources');
if (disclaimerSources) disclaimerSources.textContent = t('disclaimerSources');
const disclaimerCheckboxLabel = document.getElementById('disclaimer-checkbox-label');
if (disclaimerCheckboxLabel) disclaimerCheckboxLabel.textContent = t('disclaimerCheckbox');
const portBy = document.getElementById('port-by');
if (portBy) portBy.textContent = t('portBy');
// Update developed-by section for ruTranslate
const developedBy = document.querySelector('.developed-by');
const existingTranslatedBy = developedBy?.querySelector('.translated-by');
if (existingTranslatedBy) existingTranslatedBy.remove();
if (developedBy && t('ruTranslate')) {
developedBy.insertAdjacentHTML('beforeend', t('ruTranslate'));
}
// Update config panel labels if present
const configLangLabel = document.getElementById('config-lang-label');
if (configLangLabel) configLangLabel.textContent = t('configLanguage');
const configCheatsLabel = document.getElementById('config-cheats-label');
if (configCheatsLabel) configCheatsLabel.textContent = t('configCheats');
const configFullscreenLabel = document.getElementById('config-fullscreen-label');
if (configFullscreenLabel) configFullscreenLabel.textContent = t('configFullscreen');
const configMaxFpsLabel = document.getElementById('config-max-fps-label');
if (configMaxFpsLabel) configMaxFpsLabel.textContent = t('configMaxFps');
const configMaxFpsUnlimited = document.getElementById('config-max-fps-unlimited');
if (configMaxFpsUnlimited) configMaxFpsUnlimited.textContent = t('configUnlimited');
}
// Function to update game data files based on language
function updateGameDataForLanguage(lang) {
if (lang === 'ru') {
data_content = `${replaceBR}vc-sky-ru-v6.data.br`;
wasm_content = `${replaceBR}vc-sky-ru-v6.wasm.br`;
} else {
data_content = `${replaceBR}vc-sky-en-v6.data.br`;
wasm_content = `${replaceBR}vc-sky-en-v6.wasm.br`;
}
}
// Initialize data files based on current language
updateGameDataForLanguage(currentLanguage);
async function loadData() {
let cache;
@@ -729,4 +797,49 @@ const revc_ini = (() => {
return cached;
}
return revc_iniDefault;
})();
})();
// Configurable mode UI
if (configurableMode) {
const configPanel = document.getElementById('config-panel');
const configLang = document.getElementById('config-lang');
const configCheats = document.getElementById('config-cheats');
const configFullscreen = document.getElementById('config-fullscreen');
const configMaxFps = document.getElementById('config-max-fps');
if (configPanel && configCheats && configFullscreen && configMaxFps) {
// Show config panel
configPanel.style.display = 'block';
// Set initial values from URL params
if (configLang) configLang.value = currentLanguage;
configCheats.checked = cheatsEnabled;
configFullscreen.checked = autoFullScreen;
configMaxFps.value = maxFPS;
// Update config panel labels with current language
updateAllTranslations();
// Language selector handler
if (configLang) {
configLang.addEventListener('change', (e) => {
currentLanguage = e.target.value;
updateGameDataForLanguage(currentLanguage);
updateAllTranslations();
});
}
// Update settings when changed
configCheats.addEventListener('change', (e) => {
cheatsEnabled = e.target.checked;
});
configFullscreen.addEventListener('change', (e) => {
autoFullScreen = e.target.checked;
});
configMaxFps.addEventListener('input', (e) => {
maxFPS = parseInt(e.target.value) || 0;
});
}
}
+63
View File
@@ -511,6 +511,38 @@
font-size: 0.7em;
margin-top: 2px;
}
.config-panel {
margin: 1em auto;
padding: 1em;
border: 2px solid #2a1759;
max-width: 400px;
border-radius: 1em;
backdrop-filter: blur(20px);
text-align: left;
}
.config-option {
margin: 0.5em 0;
color: #fff;
}
.config-option label {
display: flex;
align-items: center;
gap: 0.5em;
cursor: pointer;
}
.config-option input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #ff4193;
}
.config-option input[type="number"] {
background: rgba(0,0,0,0.3);
border: 1px solid #2a1759;
color: #fff;
padding: 0.3em;
border-radius: 0.3em;
margin-left: 0.5em;
}
</style>
</head>
@@ -553,6 +585,37 @@
<div class="button-container">
<div class="cover"></div>
<div id="config-panel" class="config-panel" style="display: none;">
<div class="config-option">
<label for="config-lang">
<span id="config-lang-label">Language:</span>
<select id="config-lang" style="background: rgba(0,0,0,0.3); border: 1px solid #2a1759; color: #fff; padding: 0.3em; border-radius: 0.3em; margin-left: 0.5em;">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</label>
</div>
<div class="config-option">
<label for="config-cheats">
<input type="checkbox" id="config-cheats">
<span id="config-cheats-label">Cheats (F3)</span>
</label>
</div>
<div class="config-option">
<label for="config-fullscreen">
<input type="checkbox" id="config-fullscreen" checked>
<span id="config-fullscreen-label">Fullscreen</span>
</label>
</div>
<div class="config-option">
<label for="config-max-fps">
<span id="config-max-fps-label">Max FPS:</span>
<input type="number" id="config-max-fps" min="0" max="240" value="0" style="width: 60px;">
<span id="config-max-fps-unlimited" style="font-size: 0.8em;">(0 = unlimited)</span>
</label>
</div>
</div>
<div class="click-to-play">
<button id="click-to-play-button">Click to play</button>
<div id="demo-off-disclaimer"></div>
+2 -2
View File
@@ -1,14 +1,14 @@
(function() {
var modules = [
'modules/runtime.js',
(params.get('lang') === 'ru' ? 'modules/packages/ru.js' : 'modules/packages/en.js'),
(currentLanguage === 'ru' ? 'modules/packages/ru.js' : 'modules/packages/en.js'),
'modules/loader.js',
'modules/fs.js',
'modules/audio.js',
'modules/graphics.js',
'modules/events.js',
'modules/fetch.js',
(params.get('lang') === 'ru' ? 'modules/asm_consts/ru.js' : 'modules/asm_consts/en.js'),
(currentLanguage === 'ru' ? 'modules/asm_consts/ru.js' : 'modules/asm_consts/en.js'),
// 'modules/cheats.js',
'modules/main.js'
];
+10 -4
View File
@@ -21,15 +21,21 @@ services:
- VCBR_URL=${VCBR_URL:-}
- VCSKY_CACHE=${VCSKY_CACHE:-}
- VCBR_CACHE=${VCBR_CACHE:-}
command: >
- PACKED=${PACKED:-}
- UNPACKED=${UNPACKED:-}
- PACK=${PACK:-}
command: >
sh -c "python server.py
--port $${IN_PORT:-8000}
$$([ -n \"$$AUTH_LOGIN\" ] && [ -n \"$$AUTH_PASSWORD\" ] && echo \"--login $$AUTH_LOGIN --password $$AUTH_PASSWORD\" || echo '')
$$([ \"$$CUSTOM_SAVES\" = '1' ] && echo '--custom_saves' || echo '')
$$([ \"$$VCSKY_LOCAL\" = '1' ] && echo '--vcsky_local' || echo '')
$$([ \"$$VCBR_LOCAL\" = '1' ] && echo '--vcbr_local' || echo '')
$$(if [ \"$$VCSKY_LOCAL\" = '1' ]; then echo '--vcsky_local'; elif [ -n \"$$VCSKY_LOCAL\" ]; then echo \"--vcsky_local $$VCSKY_LOCAL\"; fi)
$$(if [ \"$$VCBR_LOCAL\" = '1' ]; then echo '--vcbr_local'; elif [ -n \"$$VCBR_LOCAL\" ]; then echo \"--vcbr_local $$VCBR_LOCAL\"; fi)
$$([ -n \"$$VCSKY_URL\" ] && echo \"--vcsky_url $$VCSKY_URL\" || echo '')
$$([ -n \"$$VCBR_URL\" ] && echo \"--vcbr_url $$VCBR_URL\" || echo '')
$$([ \"$$VCSKY_CACHE\" = '1' ] && echo '--vcsky_cache' || echo '')
$$([ \"$$VCBR_CACHE\" = '1' ] && echo '--vcbr_cache' || echo '')"
$$([ \"$$VCBR_CACHE\" = '1' ] && echo '--vcbr_cache' || echo '')
$$([ -n \"$$PACKED\" ] && echo \"--packed $$PACKED\" || echo '')
$$([ -n \"$$UNPACKED\" ] && echo \"--unpacked $$UNPACKED\" || echo '')
$$([ -n \"$$PACK\" ] && echo \"--pack $$PACK\" || echo '')"
restart: unless-stopped
+2 -1
View File
@@ -2,4 +2,5 @@ fastapi
httpx
uvicorn
brotli
python-multipart
python-multipart
aiofiles
+348 -15
View File
@@ -1,25 +1,276 @@
import os
import sys
import asyncio
import argparse
import hashlib
from typing import Optional
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
import additions.saves as saves
from additions.auth import BasicAuthMiddleware
from additions.cache import proxy_and_cache, get_local_file
from additions.packed import init_packed_archive, get_packed_file, is_initialized as packed_is_initialized
# Add utils path for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'utils'))
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--custom_saves", action="store_true")
parser.add_argument("--login", type=str)
parser.add_argument("--password", type=str)
parser.add_argument("--vcsky_local", action="store_true", help="Serve vcsky from local directory instead of proxy")
parser.add_argument("--vcbr_local", action="store_true", help="Serve vcbr from local directory instead of proxy")
parser.add_argument("--vcsky_local", type=str, nargs='?', const='vcsky', default=None,
help="Serve vcsky from local directory instead of proxy. Optionally specify path (default: vcsky/)")
parser.add_argument("--vcbr_local", type=str, nargs='?', const='vcbr', default=None,
help="Serve vcbr from local directory instead of proxy. Optionally specify path (default: vcbr/)")
parser.add_argument("--vcsky_url", type=str, default="https://cdn.dos.zone/vcsky/", help="Custom vcsky proxy URL")
parser.add_argument("--vcbr_url", type=str, default="https://br.cdn.dos.zone/vcsky/", help="Custom vcbr proxy URL")
parser.add_argument("--vcsky_cache", action="store_true", help="Cache vcsky files locally. If files are not found in the local directory, they will be downloaded from the specified URL and saved to the local directory.")
parser.add_argument("--vcbr_cache", action="store_true", help="Cache vcbr files locally. If files are not found in the local directory, they will be downloaded from the specified URL and saved to the local directory.")
parser.add_argument("--packed", type=str, nargs='?', const='revcdos.bin', default=None,
help="Serve vcsky/ and vcbr/ from packed archive. Can be a local file path or URL. "
"If URL, downloads to local file if not present. If no value specified, uses 'revcdos.bin'. "
"Supports brotli passthrough.")
parser.add_argument("--unpacked", type=str, default=None,
help="Unpack archive to local folders and serve from there. Can be a local .bin file or URL. "
"Unpacks to unpacked/{md5_hash}/ and sets vcsky_local/vcbr_local automatically. "
"If already unpacked, uses existing files without re-unpacking. "
"If URL, streams and unpacks during download using downloader_brotli.")
parser.add_argument("--pack", type=str, default=None,
help="Pack a folder to {hash}.bin archive. Can be a folder path or MD5 hash from unpacked/. "
"Packs all subfolders (vcsky/, vcbr/, etc.) into a single archive. "
"After packing, uses the archive with --packed mode to serve files.")
args = parser.parse_args()
def _md5_hash(text: str) -> str:
"""Get MD5 hash of text."""
return hashlib.md5(text.encode()).hexdigest()
def _is_url(path: str) -> bool:
"""Check if path is a URL."""
return path.startswith("http://") or path.startswith("https://")
def _is_md5_hash(text: str) -> bool:
"""Check if text is a valid MD5 hash (32 hex characters)."""
if len(text) != 32:
return False
try:
int(text, 16)
return True
except ValueError:
return False
def _get_unpacked_dir(source: str) -> str:
"""
Get unpacked directory path for a source.
If source IS a valid MD5 hash (32 hex chars), uses it directly.
Otherwise computes MD5 hash from the source string.
"""
# Check if source itself is a valid MD5 hash
if _is_md5_hash(source):
return os.path.join("unpacked", source.lower())
# Compute hash from source
source_hash = _md5_hash(source)
return os.path.join("unpacked", source_hash)
def _check_unpacked_exists(unpacked_dir: str) -> bool:
"""Check if unpacked directory exists and has content."""
if not os.path.isdir(unpacked_dir):
return False
# Check if vcsky or vcbr subdirectory exists with files
for subdir in ["vcsky", "vcbr"]:
subdir_path = os.path.join(unpacked_dir, subdir)
if os.path.isdir(subdir_path):
# Check if there are any files in subdirectories
for root, dirs, files in os.walk(subdir_path):
if files:
return True
return False
async def _unpack_from_url(url: str, output_dir: str) -> bool:
"""
Unpack archive directly from URL using streaming download.
Uses downloader_brotli for efficient stream unpacking.
"""
try:
from utils.downloader_brotli import download_and_unpack_async
print(f"Streaming and unpacking from URL: {url}")
print(f"Output directory: {output_dir}")
await download_and_unpack_async(url, output_dir)
return True
except Exception as e:
print(f"Error unpacking from URL: {e}")
return False
async def _unpack_from_file(file_path: str, output_dir: str) -> bool:
"""
Unpack archive from local file.
Uses packer_brotli.unpack_file for unpacking.
"""
try:
from utils.packer_brotli import unpack_file
print(f"Unpacking local file: {file_path}")
print(f"Output directory: {output_dir}")
# Run sync unpack in executor
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, unpack_file, file_path, output_dir)
return True
except Exception as e:
print(f"Error unpacking file: {e}")
return False
def pack_source(source: str) -> Optional[str]:
"""
Pack folder contents into {hash}.bin archive.
If source is an MD5 hash, uses unpacked/{hash}/ folder.
Otherwise uses the folder path directly.
Packs all subfolders (vcsky/, vcbr/, etc.) by:
1. Creating archive from first subfolder using pack_folder()
2. Adding remaining subfolders using add_folder()
Args:
source: Folder path or MD5 hash
Returns:
Output filename (e.g., "abc123...def.bin") or None if failed
"""
from utils.packer_brotli import pack_folder, add_folder
# Resolve source to folder path and output hash
if _is_md5_hash(source):
folder_path = os.path.join("unpacked", source.lower())
output_hash = source.lower()
else:
folder_path = source.rstrip('/\\')
output_hash = _md5_hash(os.path.basename(folder_path))
if not os.path.isdir(folder_path):
print(f"Error: Folder not found: {folder_path}")
return None
output_file = f"{output_hash}.bin"
# Get immediate subdirectories (vcsky, vcbr, etc.)
subdirs = sorted([d for d in os.listdir(folder_path)
if os.path.isdir(os.path.join(folder_path, d)) and not d.startswith('.')])
if not subdirs:
print(f"Error: No subdirectories found in {folder_path}")
return None
print(f"Packing {len(subdirs)} subfolders from {folder_path} to {output_file}")
print(f"Subfolders: {', '.join(subdirs)}")
print()
# Pack first subfolder (creates new archive)
first_subdir = os.path.join(folder_path, subdirs[0])
print(f"=== Creating archive from {subdirs[0]} ===")
pack_folder(first_subdir, output_file)
# Add remaining subfolders
for subdir_name in subdirs[1:]:
subdir_path = os.path.join(folder_path, subdir_name)
print(f"\n=== Adding {subdir_name} ===")
add_folder(output_file, subdir_path)
final_size = os.path.getsize(output_file)
print(f"\n=== Packing complete ===")
print(f"Output: {output_file} ({final_size:,} bytes)")
return output_file
async def setup_unpacked(source: str) -> tuple:
"""
Setup unpacked mode - unpack archive if needed and return local paths.
Args:
source: Local file path, URL to packed archive, or MD5 hash of existing unpacked folder
Returns:
Tuple of (vcsky_local_path, vcbr_local_path) or (None, None) if failed
"""
unpacked_dir = _get_unpacked_dir(source)
# Check if source is just an MD5 hash (use existing folder only)
is_hash_only = _is_md5_hash(source)
# Check if already unpacked
if _check_unpacked_exists(unpacked_dir):
print(f"Using existing unpacked directory: {unpacked_dir}")
elif is_hash_only:
# Source is MD5 hash but folder doesn't exist - error
print(f"Error: Unpacked folder not found for hash: {source}")
print(f"Expected directory: {unpacked_dir}")
return None, None
else:
# Need to unpack
print(f"Unpacking to: {unpacked_dir}")
os.makedirs(unpacked_dir, exist_ok=True)
if _is_url(source):
# Stream unpack from URL
success = await _unpack_from_url(source, unpacked_dir)
else:
# Unpack from local file
if not os.path.isfile(source):
print(f"Error: Archive file not found: {source}")
return None, None
success = await _unpack_from_file(source, unpacked_dir)
if not success:
print(f"Failed to unpack from: {source}")
return None, None
# Determine vcsky and vcbr paths
vcsky_path = None
vcbr_path = None
# Check for vcsky folder
vcsky_candidate = os.path.join(unpacked_dir, "vcsky")
if os.path.isdir(vcsky_candidate):
vcsky_path = vcsky_candidate
print(f" vcsky: {vcsky_path}")
# Check for vcbr folder
vcbr_candidate = os.path.join(unpacked_dir, "vcbr")
if os.path.isdir(vcbr_candidate):
vcbr_path = vcbr_candidate
print(f" vcbr: {vcbr_path}")
if not vcsky_path and not vcbr_path:
print(f"Warning: No vcsky or vcbr folders found in {unpacked_dir}")
# Maybe the folders are directly in unpacked_dir without vcsky/vcbr prefix
# Check if there's a subfolder that looks like the archive name
for item in os.listdir(unpacked_dir):
item_path = os.path.join(unpacked_dir, item)
if os.path.isdir(item_path):
vcsky_sub = os.path.join(item_path, "vcsky")
vcbr_sub = os.path.join(item_path, "vcbr")
if os.path.isdir(vcsky_sub):
vcsky_path = vcsky_sub
if os.path.isdir(vcbr_sub):
vcbr_path = vcbr_sub
return vcsky_path, vcbr_path
app = FastAPI()
if args.login and args.password:
@@ -31,6 +282,11 @@ if args.custom_saves:
VCSKY_BASE_URL = args.vcsky_url
VCBR_BASE_URL = args.vcbr_url
# Local paths (can be overridden by --unpacked)
VCSKY_LOCAL_PATH = args.vcsky_local # None, 'vcsky', or custom path
VCBR_LOCAL_PATH = args.vcbr_local # None, 'vcbr', or custom path
def request_to_url(request: Request, path: str, base_url: str):
query_string = str(request.url.query) if request.url.query else ""
url = f"{base_url}{path}"
@@ -38,32 +294,59 @@ def request_to_url(request: Request, path: str, base_url: str):
url = f"{url}?{query_string}"
return url
# vcsky routes - either local or proxy
# vcsky routes - packed archive, local, or proxy
@app.api_route("/vcsky/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
async def vc_sky_proxy(request: Request, path: str):
local_path = os.path.join("vcsky", path)
if args.vcsky_local:
# Try packed archive first if enabled
if args.packed and packed_is_initialized():
packed_path = f"vcsky/{path}"
if response := await get_packed_file(packed_path, request):
return response
# Try local directory
if VCSKY_LOCAL_PATH:
local_path = os.path.join(VCSKY_LOCAL_PATH, path)
if response := get_local_file(local_path, request):
return response
raise HTTPException(status_code=404, detail="File not found")
# If local mode is explicitly set, don't fall through to proxy
if args.vcsky_local is not None or args.unpacked:
raise HTTPException(status_code=404, detail="File not found")
# Proxy mode
url = request_to_url(request, path, VCSKY_BASE_URL)
if args.vcsky_cache:
return await proxy_and_cache(request, url, local_path)
cache_path = os.path.join("vcsky", path)
return await proxy_and_cache(request, url, cache_path)
return await proxy_and_cache(request, url, disable_cache=True)
# vcbr routes - either local or proxy
# vcbr routes - packed archive, local, or proxy
@app.api_route("/vcbr/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"])
async def vc_br_proxy(request: Request, path: str):
local_path = os.path.join("vcbr", path)
if args.vcbr_local:
# Try packed archive first if enabled
if args.packed and packed_is_initialized():
packed_path = f"vcbr/{path}"
if response := await get_packed_file(packed_path, request):
return response
# Try local directory
if VCBR_LOCAL_PATH:
local_path = os.path.join(VCBR_LOCAL_PATH, path)
if response := get_local_file(local_path, request):
return response
raise HTTPException(status_code=404, detail="File not found")
# If local mode is explicitly set, don't fall through to proxy
if args.vcbr_local is not None or args.unpacked:
raise HTTPException(status_code=404, detail="File not found")
# Proxy mode
url = request_to_url(request, path, VCBR_BASE_URL)
if args.vcbr_cache:
return await proxy_and_cache(request, url, local_path)
cache_path = os.path.join("vcbr", path)
return await proxy_and_cache(request, url, cache_path)
return await proxy_and_cache(request, url, disable_cache=True)
@app.get("/")
async def read_index():
if os.path.exists("dist/index.html"):
@@ -85,12 +368,62 @@ async def read_index():
app.mount("/", StaticFiles(directory="dist"), name="root")
async def init_server():
"""Initialize server components that need async init."""
global VCSKY_LOCAL_PATH, VCBR_LOCAL_PATH
# Handle --unpacked mode first (takes precedence)
if args.unpacked:
vcsky_path, vcbr_path = await setup_unpacked(args.unpacked)
if vcsky_path:
VCSKY_LOCAL_PATH = vcsky_path
if vcbr_path:
VCBR_LOCAL_PATH = vcbr_path
# Handle --packed mode
if args.packed:
# init_packed_archive handles both local paths and URLs
# If URL is provided, it will download the file if not present locally
result = await init_packed_archive(args.packed)
if result is None:
print(f"Warning: Failed to initialize packed archive from: {args.packed}")
def start_server(app=app, host="0.0.0.0", port=args.port):
import uvicorn
# Initialize server components
if args.packed or args.unpacked:
asyncio.run(init_server())
uvicorn.run(app, host=host, port=port)
if __name__ == "__main__":
# Handle --pack first (pack folder then use packed mode)
if args.pack:
print(f"Pack mode: {args.pack}")
packed_file = pack_source(args.pack)
if packed_file:
print(f"\nUsing packed archive: {packed_file}")
args.packed = packed_file
else:
print("Packing failed, exiting.")
sys.exit(1)
print(f"Starting server on http://localhost:{args.port}")
print(f"vcsky: {'local' if args.vcsky_local else 'proxy'} ({VCSKY_BASE_URL if not args.vcsky_local else 'vcsky/'})")
print(f"vcbr: {'local' if args.vcbr_local else 'proxy'} ({VCBR_BASE_URL if not args.vcbr_local else 'vcbr/'})")
start_server()
if args.unpacked:
print(f"unpacked mode: {args.unpacked}")
elif args.packed:
print(f"packed: {args.packed}")
else:
vcsky_mode = 'local' if args.vcsky_local else 'proxy'
vcbr_mode = 'local' if args.vcbr_local else 'proxy'
vcsky_info = args.vcsky_local or VCSKY_BASE_URL
vcbr_info = args.vcbr_local or VCBR_BASE_URL
print(f"vcsky: {vcsky_mode} ({vcsky_info})")
print(f"vcbr: {vcbr_mode} ({vcbr_info})")
start_server()
+457
View File
@@ -0,0 +1,457 @@
#!/usr/bin/env python3
"""
Downloader for packed files with brotli-compressed content.
Downloads and unpacks directly to disk without saving intermediate .bin file.
Shows detailed progress and statistics.
Uses separate coroutines for downloading and unpacking with asyncio.Queue.
This version uses packer_brotli format where individual files are brotli-compressed.
The stream_unpack_async from packer_brotli automatically decompresses each file.
"""
import os
import sys
import asyncio
import shutil
import time
from dataclasses import dataclass, field
from typing import Dict, Optional, Tuple
import httpx
import aiofiles
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from packer_brotli import stream_unpack_async
def format_size(size_bytes: int) -> str:
"""Format bytes to human readable size."""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024:
return f"{size_bytes:.2f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.2f} TB"
def format_time(seconds: float) -> str:
"""Format seconds to human readable time."""
if seconds < 60:
return f"{seconds:.1f}s"
elif seconds < 3600:
minutes = int(seconds // 60)
secs = seconds % 60
return f"{minutes}m {secs:.1f}s"
else:
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
return f"{hours}h {minutes}m"
def get_terminal_width() -> int:
"""Get terminal width, default to 80 if not available."""
try:
return shutil.get_terminal_size().columns
except Exception:
return 80
@dataclass
class UnpackStats:
"""Statistics for unpacking progress."""
start_time: float = field(default_factory=time.time)
# Folder tracking
current_folder: str = ""
files_in_current_folder: int = 0
unpacked_in_current_folder: int = 0
# Global tracking
total_folders: int = 0
total_files: int = 0
total_bytes: int = 0 # Decompressed file content bytes
total_compressed_bytes: int = 0 # Compressed file bytes in archive
copied_folders: int = 0
copied_files: int = 0 # Individual file copies (references)
# Download tracking
downloaded_bytes: int = 0
download_complete: bool = False
# Per-folder stats
folder_stats: Dict[str, Dict] = field(default_factory=dict)
folder_file_counts: Dict[str, int] = field(default_factory=dict)
# Track unpacked folders and files for copy references
unpacked_folders: Dict[str, str] = field(default_factory=dict)
unpacked_files: Dict[Tuple[str, str], str] = field(default_factory=dict)
# Last printed line length for proper clearing
last_line_length: int = 0
def start_folder(self, folder_name: str, num_files: int):
"""Start tracking a new folder."""
self.current_folder = folder_name
self.files_in_current_folder = num_files
self.unpacked_in_current_folder = 0
self.total_folders += 1
self.folder_stats[folder_name] = {
'total_files': num_files,
'unpacked_files': 0,
'total_bytes': 0,
'compressed_bytes': 0
}
self.folder_file_counts[folder_name] = num_files
def file_unpacked(self, filename: str, compressed_size: int, decompressed_size: int):
"""Record a file being unpacked."""
self.unpacked_in_current_folder += 1
self.total_files += 1
self.total_bytes += decompressed_size
self.total_compressed_bytes += compressed_size
if self.current_folder in self.folder_stats:
self.folder_stats[self.current_folder]['unpacked_files'] += 1
self.folder_stats[self.current_folder]['total_bytes'] += decompressed_size
self.folder_stats[self.current_folder]['compressed_bytes'] += compressed_size
def file_copied(self, filename: str, file_size: int):
"""Record a file being copied from a reference."""
self.unpacked_in_current_folder += 1
self.total_files += 1
self.total_bytes += file_size
self.copied_files += 1
if self.current_folder in self.folder_stats:
self.folder_stats[self.current_folder]['unpacked_files'] += 1
self.folder_stats[self.current_folder]['total_bytes'] += file_size
def add_downloaded(self, size: int):
"""Add downloaded bytes."""
self.downloaded_bytes += size
def get_elapsed(self) -> float:
"""Get elapsed time in seconds."""
return time.time() - self.start_time
def clear_line(self):
"""Clear the current line properly."""
term_width = get_terminal_width()
print('\r' + ' ' * min(self.last_line_length, term_width - 1) + '\r', end='', flush=True)
def print_progress(self, filename: str, compressed_size: int, decompressed_size: int, is_copy: bool = False):
"""Print current progress on a single line."""
elapsed = self.get_elapsed()
speed = self.total_bytes / elapsed if elapsed > 0 else 0
# Calculate progress percentage
if self.files_in_current_folder > 0:
progress_pct = self.unpacked_in_current_folder / self.files_in_current_folder * 100
else:
progress_pct = 0
# Progress bar
bar_len = 15
filled = int(bar_len * self.unpacked_in_current_folder / self.files_in_current_folder) if self.files_in_current_folder > 0 else 0
bar = '█' * filled + '░' * (bar_len - filled)
# Truncate folder name if too long
folder_display = self.current_folder
if len(folder_display) > 25:
folder_display = '...' + folder_display[-22:]
# Truncate filename if too long
file_display = filename
if len(file_display) > 15:
file_display = file_display[:12] + '...'
# Download indicator
dl_indicator = "⬇️" if not self.download_complete else "✓"
# Copy indicator
copy_marker = "📋" if is_copy else ""
# Compression info
if not is_copy and compressed_size > 0:
ratio = decompressed_size / compressed_size if compressed_size > 0 else 1
size_info = f"{format_size(compressed_size)}->{format_size(decompressed_size)} ({ratio:.1f}x)"
else:
size_info = format_size(decompressed_size)
# Build the line
line = (f"[{bar}] {progress_pct:5.1f}% | "
f"{folder_display} | "
f"{self.unpacked_in_current_folder}/{self.files_in_current_folder}: {copy_marker}{file_display} | "
f"{size_info} | "
f"{format_size(speed)}/s | "
f"{dl_indicator} {format_size(self.downloaded_bytes)}")
# Get terminal width and truncate if needed
term_width = get_terminal_width()
if len(line) > term_width - 1:
line = line[:term_width - 4] + '...'
# Clear previous line and print new one
self.clear_line()
print(line, end='', flush=True)
self.last_line_length = len(line)
def print_folder_complete(self):
"""Print folder completion message."""
self.clear_line()
folder_data = self.folder_stats.get(self.current_folder, {})
compressed = folder_data.get('compressed_bytes', 0)
decompressed = folder_data.get('total_bytes', 0)
if compressed > 0:
ratio = decompressed / compressed
print(f"✓ {self.current_folder}: "
f"{folder_data.get('unpacked_files', 0)} files, "
f"{format_size(compressed)} -> {format_size(decompressed)} ({ratio:.1f}x)")
else:
print(f"✓ {self.current_folder}: "
f"{folder_data.get('unpacked_files', 0)} files, "
f"{format_size(decompressed)}")
def print_summary(self, output_dir: str):
"""Print final summary."""
elapsed = self.get_elapsed()
speed = self.total_bytes / elapsed if elapsed > 0 else 0
print("\n" + "=" * 60)
print(" UNPACKING COMPLETE")
print("=" * 60)
print(f" Output directory: {output_dir}")
print(f" Total time: {format_time(elapsed)}")
print(f" Average speed: {format_size(speed)}/s")
print("-" * 60)
print(f" Folders: {self.total_folders}")
if self.copied_folders > 0:
print(f" Copied folders: {self.copied_folders}")
print(f" Files: {self.total_files}")
if self.copied_files > 0:
print(f" Copied files: {self.copied_files}")
print(f" Total size: {format_size(self.total_bytes)}")
print("-" * 60)
print(f" Downloaded: {format_size(self.downloaded_bytes)}")
if self.total_compressed_bytes > 0:
file_ratio = self.total_bytes / self.total_compressed_bytes if self.total_compressed_bytes > 0 else 1
print(f" File compression: {format_size(self.total_compressed_bytes)} -> {format_size(self.total_bytes)} ({file_ratio:.1f}x)")
print("=" * 60)
# Top 5 largest folders
if self.folder_stats:
print("\n Top folders by size:")
sorted_folders = sorted(
self.folder_stats.items(),
key=lambda x: x[1]['total_bytes'],
reverse=True
)[:5]
for folder, data in sorted_folders:
compressed = data.get('compressed_bytes', 0)
decompressed = data.get('total_bytes', 0)
if compressed > 0:
ratio = decompressed / compressed
print(f" {folder}: {data['unpacked_files']} files, {format_size(decompressed)} ({ratio:.1f}x)")
else:
print(f" {folder}: {data['unpacked_files']} files, {format_size(decompressed)}")
async def download_and_unpack_async(url: str, output_dir: str, chunk_size: int = 65536, queue_maxsize: int = 100) -> None:
"""
Download a packed file (with brotli-compressed files) and unpack directly to disk (async).
Uses separate tasks for downloading and unpacking with asyncio.Queue for buffering.
Individual files in the archive are brotli-compressed and will be decompressed
automatically by stream_unpack_async from packer_brotli.
Args:
url: URL of the packed .bin file
output_dir: Directory to unpack files into
chunk_size: Size of chunks to download
queue_maxsize: Max size of the buffer queue (0 for unlimited)
"""
stats = UnpackStats()
queue: asyncio.Queue[Optional[bytes]] = asyncio.Queue(maxsize=queue_maxsize)
async def download_task():
"""Download data and put into queue."""
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0), follow_redirects=True) as client:
async with client.stream('GET', url) as response:
response.raise_for_status()
content_length = response.headers.get('content-length')
if content_length:
print(f"📥 Downloading from {url}")
print(f" Remote file size: {format_size(int(content_length))}")
print(f" Files are brotli-compressed (will decompress)")
else:
print(f"📥 Downloading from {url}")
print(f" Files are brotli-compressed (will decompress)")
print()
async for chunk in response.aiter_bytes(chunk_size):
stats.add_downloaded(len(chunk))
await queue.put(chunk)
finally:
# Signal end of download
await queue.put(None)
stats.download_complete = True
async def queue_to_async_iter():
"""Convert queue to async iterator for stream_unpack_async."""
while True:
chunk = await queue.get()
if chunk is None:
break
yield chunk
# Start download task
download_coro = asyncio.create_task(download_task())
try:
last_folder = None
async for folder_name, num_files, file_idx, filename, file_size, file_chunks, source_ref in stream_unpack_async(queue_to_async_iter()):
# Create folder path
folder_path = os.path.join(output_dir, folder_name)
os.makedirs(folder_path, exist_ok=True)
stats.unpacked_folders[folder_name] = folder_path
# Check if this is a copy folder marker (file_idx == -1)
if file_idx == -1:
# This is a copy folder, filename is actually source folder name
source_name = filename
source_path = stats.unpacked_folders.get(source_name)
# Print completion for previous folder if needed
if last_folder is not None and last_folder != folder_name:
stats.print_folder_complete()
stats.total_folders += 1
stats.copied_folders += 1
if source_path and os.path.exists(source_path):
# Copy files from source folder
copied_count = 0
copied_bytes = 0
for fname in os.listdir(source_path):
src_file = os.path.join(source_path, fname)
dst_file = os.path.join(folder_path, fname)
if os.path.isfile(src_file):
shutil.copy2(src_file, dst_file)
stats.unpacked_files[(folder_name, fname)] = dst_file
copied_count += 1
copied_bytes += os.path.getsize(src_file)
stats.total_files += copied_count
stats.total_bytes += copied_bytes
stats.folder_stats[folder_name] = {
'total_files': copied_count,
'unpacked_files': copied_count,
'total_bytes': copied_bytes,
'compressed_bytes': 0
}
stats.clear_line()
print(f"📋 {folder_name} <- {source_name}: {copied_count} files, {format_size(copied_bytes)}")
else:
stats.clear_line()
print(f"⚠️ {folder_name}: Source folder not found: {source_name}")
last_folder = folder_name
elif file_size == -2:
# File reference - copy from another file
src_folder, src_filename = source_ref
src_file_path = stats.unpacked_files.get((src_folder, src_filename))
# Check if we started a new folder
if folder_name != last_folder:
if last_folder is not None:
stats.print_folder_complete()
last_folder = folder_name
stats.start_folder(folder_name, num_files)
file_path = os.path.join(folder_path, filename)
if src_file_path and os.path.exists(src_file_path):
shutil.copy2(src_file_path, file_path)
stats.unpacked_files[(folder_name, filename)] = file_path
actual_size = os.path.getsize(file_path)
stats.file_copied(filename, actual_size)
stats.print_progress(filename, 0, actual_size, is_copy=True)
else:
stats.clear_line()
print(f"⚠️ {folder_name}/{filename}: Source file not found: {src_folder}/{src_filename}")
else:
# Normal file - file_chunks yields already-decompressed data
# Check if we started a new folder
if folder_name != last_folder:
# Print completion for previous folder
if last_folder is not None:
stats.print_folder_complete()
last_folder = folder_name
# Now we know the exact number of files from the binary header
stats.start_folder(folder_name, num_files)
# Write file - chunks are already decompressed by stream_unpack_async
file_path = os.path.join(folder_path, filename)
decompressed_size = 0
async with aiofiles.open(file_path, 'wb') as f:
async for chunk in file_chunks:
await f.write(chunk)
decompressed_size += len(chunk)
stats.unpacked_files[(folder_name, filename)] = file_path
# file_size is the compressed size from the archive
# decompressed_size is the actual file size after decompression
compressed_size = file_size
# Update stats
stats.file_unpacked(filename, compressed_size, decompressed_size)
# Print progress
stats.print_progress(filename, compressed_size, decompressed_size)
# Print completion for last folder
if last_folder is not None and last_folder in stats.folder_stats:
if stats.folder_stats[last_folder].get('unpacked_files', 0) < stats.folder_stats[last_folder].get('total_files', 0):
stats.print_folder_complete()
elif stats.unpacked_in_current_folder > 0:
stats.print_folder_complete()
# Wait for download to complete (should already be done)
await download_coro
except Exception as e:
download_coro.cancel()
raise
# Print final summary
stats.print_summary(output_dir)
# ============== CLI ==============
def main():
if len(sys.argv) < 3:
print("Usage: python downloader_brotli.py <url> <output_dir>")
print()
print("Downloads a packed file (packer_brotli format) and unpacks directly to disk.")
print("Files in the archive are brotli-compressed and will be decompressed automatically.")
print("Shows detailed progress and statistics during unpacking.")
print("Downloads and unpacks run in parallel using async queue buffering.")
print()
print("Example:")
print(" python downloader_brotli.py https://example.com/files.bin ./unpacked")
sys.exit(1)
url = sys.argv[1]
output_dir = sys.argv[2]
asyncio.run(download_and_unpack_async(url, output_dir))
if __name__ == '__main__':
main()
File diff suppressed because it is too large Load Diff