Buckets:
| #!/usr/bin/env python3 | |
| """Play the offline-games archive in a normal browser. | |
| Browsers block web games opened straight from disk (file://): the page appears, then the loader waits forever | |
| because the browser refuses its data requests. These games must be served over HTTP from the site's own paths. | |
| This script does that, using only the Python standard library (Python 3.8+): | |
| python play.py # then open http://localhost:8000/ | |
| python play.py --port 9000 --dir D:/offline-games | |
| python play.py --offline # use only files already on disk | |
| - Files are read from DIR (default: ./offline-games, laid out like the bucket: site/..., _vendor/..., _reports/...). | |
| - A file that is not on disk yet is downloaded once from the public bucket and kept, so you can play any game | |
| without downloading all of it first. With --offline nothing is downloaded. | |
| - Responses carry the headers the original site used (cross-origin isolation, correct MIME types, byte ranges). | |
| - Public libraries that some games load from CDNs are served from the archive's _vendor/ copies when present. | |
| - http://localhost:PORT/ lists every captured game with a link. | |
| """ | |
| import argparse, html, json, mimetypes, os, re, shutil, sys, threading, time, urllib.error, urllib.request | |
| from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | |
| from urllib.parse import quote, unquote, urlparse | |
| BUCKET = 'https://huggingface.co/buckets/smodusermc/offline-games/resolve/' | |
| SITE = b'garbsoftball.com' | |
| VENDORS = ('www.gstatic.com', 'cdnjs.cloudflare.com', 'unpkg.com', 'cdn.jsdelivr.net', 'ajax.googleapis.com', | |
| 'maxcdn.bootstrapcdn.com', 'cdn-factory.marketjs.com', 'cdn.fbrq.io', 'www.youtube.com') | |
| TYPES = {'.wasm': 'application/wasm', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8', | |
| '.json': 'application/json', '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8', '.css': 'text/css', | |
| '.svg': 'image/svg+xml', '.webp': 'image/webp', '.ogg': 'audio/ogg', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', | |
| '.m4a': 'audio/mp4', '.mp4': 'video/mp4', '.webm': 'video/webm', '.swf': 'application/x-shockwave-flash', | |
| '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', '.txt': 'text/plain; charset=utf-8', | |
| '.xml': 'application/xml', '.ico': 'image/x-icon'} | |
| locks, locks_guard = {}, threading.Lock() | |
| not_in_bucket = set() | |
| def args(): | |
| a = argparse.ArgumentParser(description='Serve the offline-games archive for play in a browser.') | |
| a.add_argument('--port', type=int, default=8000) | |
| a.add_argument('--dir', default='offline-games', help='local folder for the archive files (default: ./offline-games)') | |
| a.add_argument('--offline', action='store_true', help='never download; use only files already on disk') | |
| a.add_argument('--host', default='127.0.0.1', help='address to listen on (default 127.0.0.1)') | |
| return a.parse_args() | |
| def fetch(key, dest): | |
| """Download bucket object `key` to `dest` once (thread-safe). Returns True if the file exists afterwards.""" | |
| if dest.is_file() or OPT.offline or key in not_in_bucket: return dest.is_file() | |
| with locks_guard: lk = locks.setdefault(key, threading.Lock()) | |
| with lk: | |
| if dest.is_file(): return True | |
| dest.parent.mkdir(parents=True, exist_ok=True); tmp = dest.with_name(dest.name + '.part') | |
| for attempt in range(4): | |
| try: | |
| req = urllib.request.Request(BUCKET + quote(key), headers={'User-Agent': 'offline-games-player'}) | |
| with urllib.request.urlopen(req, timeout=60) as r, open(tmp, 'wb') as fh: shutil.copyfileobj(r, fh, 1 << 20) | |
| os.replace(tmp, dest); print(' downloaded', key, flush=True); return True | |
| except urllib.error.HTTPError as e: | |
| if e.code in (403, 404): not_in_bucket.add(key); return False | |
| if e.code in (429, 500, 502, 503, 504): time.sleep(2 * (attempt + 1)); continue | |
| print(' download failed', key, e, flush=True); return False | |
| except Exception as e: | |
| print(' download error', key, e, flush=True); time.sleep(2 * (attempt + 1)) | |
| return False | |
| def catalog_page(): | |
| inv_path = ROOT / '_reports' / 'inventory.json' | |
| if not fetch('_reports/inventory.json', inv_path): return b'<p>inventory.json not available (offline and not on disk).</p>' | |
| inv = json.loads(inv_path.read_text(encoding='utf-8')) | |
| rows = [r for r in inv if r.get('status') in ('partial_static_capture', 'static_capture_unverified')] | |
| rows.sort(key=lambda r: r['name'].lower()) | |
| items = ''.join(f'<li><a href="{html.escape(urlparse(r["entry"]).path)}">{html.escape(r["name"])}</a> <small>#{r["id"]}</small></li>' for r in rows) | |
| return (f'<!doctype html><meta charset="utf-8"><title>Offline games</title><body style="font-family:sans-serif;max-width:900px;margin:2em auto">' | |
| f'<h1>Offline games ({len(rows)})</h1><p>Files download from the archive the first time a game is opened, so the first start can take a while ' | |
| f'for large games. Afterwards they are read from disk.</p><input id=q placeholder="filter" oninput="for(const li of document.querySelectorAll(\'li\'))' | |
| f'li.style.display=li.textContent.toLowerCase().includes(this.value.toLowerCase())?\'\':\'none\'"><ul style="columns:2">{items}</ul>').encode() | |
| class Handler(BaseHTTPRequestHandler): | |
| protocol_version = 'HTTP/1.1' | |
| def log_message(self, fmt, *a): pass | |
| def send_body(self, code, body, ctype): | |
| self.send_response(code); self.common(ctype); self.send_header('Content-Length', str(len(body))); self.end_headers() | |
| if self.command != 'HEAD': self.wfile.write(body) | |
| def common(self, ctype): | |
| self.send_header('Content-Type', ctype) | |
| for k, v in (('Cross-Origin-Opener-Policy', 'same-origin'), ('Cross-Origin-Embedder-Policy', 'credentialless'), | |
| ('Cross-Origin-Resource-Policy', 'cross-origin'), ('Access-Control-Allow-Origin', '*'), ('Cache-Control', 'no-cache'), | |
| ('Accept-Ranges', 'bytes')): | |
| self.send_header(k, v) | |
| def do_HEAD(self): self.do_GET() | |
| def do_GET(self): | |
| path = re.sub(r'/{2,}', '/', unquote(urlparse(self.path).path)) | |
| if path in ('/', '/__games'): return self.send_body(200, catalog_page(), 'text/html; charset=utf-8') | |
| if '..' in path.split('/'): return self.send_body(400, b'bad path', 'text/plain') | |
| if path.startswith('/__vendor/'): key = '_vendor/' + path[len('/__vendor/'):] | |
| else: key = 'site' + path + ('index.html' if path.endswith('/') else '') | |
| f = ROOT / key | |
| if not fetch(key, f): | |
| if path.startswith('/__vendor/') and not OPT.offline: # not archived: fall back to the public CDN | |
| self.send_response(302); self.send_header('Location', 'https://' + path[len('/__vendor/'):]); self.send_header('Content-Length', '0'); self.end_headers(); return | |
| return self.send_body(404, b'not in the archive', 'text/plain') | |
| ext = os.path.splitext(f.name)[1].lower() | |
| ctype = TYPES.get(ext) or mimetypes.guess_type(f.name)[0] or 'application/octet-stream' | |
| size = f.stat().st_size | |
| if size < (16 << 20) and ext in ('.html', '.htm', '.js', '.mjs', '.css', '.json'): | |
| data = f.read_bytes().replace(b'https://' + SITE, b'').replace(b'http://' + SITE, b'') | |
| for h in VENDORS: data = data.replace(b'https://' + h.encode(), b'/__vendor/' + h.encode()) | |
| return self.send_body(200, data, ctype) | |
| start, end, code = 0, size - 1, 200 | |
| m = re.match(r'bytes=(\d*)-(\d*)$', self.headers.get('Range', '')) | |
| if m and size: | |
| if m.group(1): start = int(m.group(1)); end = int(m.group(2)) if m.group(2) else size - 1 | |
| else: start = max(0, size - int(m.group(2) or 0)) | |
| end = min(end, size - 1); code = 206 | |
| if start > end: self.send_response(416); self.send_header('Content-Range', f'bytes */{size}'); self.send_header('Content-Length', '0'); self.end_headers(); return | |
| self.send_response(code); self.common(ctype); self.send_header('Content-Length', str(end - start + 1)) | |
| if code == 206: self.send_header('Content-Range', f'bytes {start}-{end}/{size}') | |
| self.end_headers() | |
| if self.command == 'HEAD': return | |
| try: | |
| with open(f, 'rb') as fh: | |
| fh.seek(start); left = end - start + 1 | |
| while left > 0: | |
| chunk = fh.read(min(1 << 20, left)) | |
| if not chunk: break | |
| self.wfile.write(chunk); left -= len(chunk) | |
| except (BrokenPipeError, ConnectionResetError): pass | |
| if __name__ == '__main__': | |
| OPT = args(); ROOT = __import__('pathlib').Path(OPT.dir).resolve(); ROOT.mkdir(parents=True, exist_ok=True) | |
| srv = ThreadingHTTPServer((OPT.host, OPT.port), Handler); srv.daemon_threads = True | |
| print(f'Offline games: http://localhost:{OPT.port}/ (files in {ROOT}{", offline" if OPT.offline else ""}; Ctrl+C to stop)', flush=True) | |
| try: srv.serve_forever() | |
| except KeyboardInterrupt: print('stopped') | |
Xet Storage Details
- Size:
- 9.26 kB
- Xet hash:
- d6e2baf590eca98854fee321271559d6fafdb5fdb827d176c29c835ba0bcd02c
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.