mirror of
https://github.com/Lolendor/reVCDOS.git
synced 2026-10-10 03:43:12 +08:00
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:
@@ -7,3 +7,5 @@ __pycache__/
|
||||
vcbr/
|
||||
vcsky/
|
||||
saves/
|
||||
unpacked/
|
||||
*.bin
|
||||
@@ -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/
|
||||
└── ...
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Vendored
+194
-81
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
Vendored
+63
@@ -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>
|
||||
|
||||
Vendored
+2
-2
@@ -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
@@ -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
@@ -2,4 +2,5 @@ fastapi
|
||||
httpx
|
||||
uvicorn
|
||||
brotli
|
||||
python-multipart
|
||||
python-multipart
|
||||
aiofiles
|
||||
@@ -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()
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user