|
|
""" |
|
|
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 |
|
|
|
|
|
|
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'utils')) |
|
|
from utils.packer_brotli import PackedArchive |
|
|
|
|
|
|
|
|
_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() |
|
|
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): |
|
|
|
|
|
return source |
|
|
|
|
|
|
|
|
filename = _get_filename_from_url(source) |
|
|
local_path = filename |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
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"): |
|
|
|
|
|
return "application/octet-stream" |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
if not _archive.exists(path): |
|
|
return None |
|
|
|
|
|
|
|
|
client_accepts_br = _client_accepts_brotli(request) |
|
|
|
|
|
|
|
|
is_br_file = _is_br_file(path) |
|
|
|
|
|
|
|
|
media_type = _get_media_type(path) |
|
|
|
|
|
try: |
|
|
if is_br_file: |
|
|
|
|
|
|
|
|
async with _archive.open(path, keep_brotli=False) as f: |
|
|
br_data = f.read() |
|
|
|
|
|
if client_accepts_br: |
|
|
|
|
|
headers = _get_response_headers(use_brotli=True, media_type=media_type) |
|
|
return Response(content=br_data, headers=headers) |
|
|
else: |
|
|
|
|
|
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: |
|
|
|
|
|
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: |
|
|
|
|
|
async with _archive.open(path, keep_brotli=False) as f: |
|
|
br_data = f.data |
|
|
|
|
|
if client_accepts_br: |
|
|
|
|
|
for i in range(0, len(br_data), chunk_size): |
|
|
yield br_data[i:i + chunk_size] |
|
|
else: |
|
|
|
|
|
decompressed_data = brotli.decompress(br_data) |
|
|
for i in range(0, len(decompressed_data), chunk_size): |
|
|
yield decompressed_data[i:i + chunk_size] |
|
|
else: |
|
|
|
|
|
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() |
|
|
|