mirror of
https://github.com/Lolendor/reVCDOS.git
synced 2026-10-10 20:03:14 +08:00
19edc9df14
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, ...
412 lines
14 KiB
Python
412 lines
14 KiB
Python
"""
|
|
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()
|