Last active
August 20, 2026 17:27
-
-
Save hypafrag/2d415a92e8e2427be6d5e6278ad1bb53 to your computer and use it in GitHub Desktop.
Serve local .pkg files to PS4 Remote Package Installer with space-free URLs. Files are renamed to <md5-of-abs-path>.pkg so paths never contain spaces. Connections are restricted to the PS4 IP only. Exits 5 seconds after the last PS4 connection closes. Usage: python3 ps4-send.py file1.pkg [file2.pkg ...]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Serve local .pkg files to PS4 Remote Package Installer with space-free URLs. | |
| Files are renamed to <md5-of-abs-path>.pkg so paths never contain spaces. | |
| Connections are restricted to the PS4 IP only. | |
| Exits 5 seconds after the last PS4 connection closes. | |
| Usage: python3 ps4-send.py file1.pkg [file2.pkg ...] | |
| """ | |
| import hashlib | |
| import http.server | |
| import json | |
| import os | |
| import re | |
| import socket | |
| import sys | |
| import threading | |
| import time | |
| import urllib.request | |
| PS4_IP = "192.168.1.10" | |
| PS4_API_URL = f"http://{PS4_IP}:12800/api/install" | |
| SERVE_PORT = 8012 | |
| IDLE_TIMEOUT = 5.0 | |
| file_map: dict[str, str] = {} # hash_filename -> abs_path | |
| file_handles: dict[str, object] = {} # hash_filename -> open file object | |
| _active_connections = 0 | |
| _idle_since: float | None = None | |
| _has_connected = False | |
| _conn_lock = threading.Lock() | |
| def check_ps4_reachable() -> bool: | |
| s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
| s.settimeout(3) | |
| try: | |
| s.connect((PS4_IP, 12800)) | |
| return True | |
| except (OSError, socket.timeout): | |
| return False | |
| finally: | |
| s.close() | |
| def get_local_ip() -> str: | |
| s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | |
| try: | |
| s.connect((PS4_IP, 12800)) | |
| return s.getsockname()[0] | |
| finally: | |
| s.close() | |
| def path_to_filename(abs_path: str) -> str: | |
| return hashlib.md5(abs_path.encode()).hexdigest() + ".pkg" | |
| def any_task_running(task_ids: list[int]) -> bool: | |
| for task_id in task_ids: | |
| try: | |
| r = ps4_post("/api/get_task_progress", {"task_id": task_id}) | |
| if not r or r.get("error", 0) != 0: | |
| continue | |
| if r.get("preparing_percent", 100) < 100: | |
| return True | |
| length_total = r.get("length_total", 0) | |
| if length_total > 0 and r.get("transferred_total", 0) < length_total: | |
| return True | |
| if r.get("local_copy_percent", 100) < 100: | |
| return True | |
| except Exception: | |
| pass | |
| return False | |
| def wait_for_idle(task_ids: list[int]) -> None: | |
| global _idle_since | |
| while True: | |
| timed_out = False | |
| with _conn_lock: | |
| if (_has_connected | |
| and _active_connections == 0 | |
| and _idle_since is not None | |
| and time.monotonic() - _idle_since >= IDLE_TIMEOUT): | |
| timed_out = True | |
| if timed_out: | |
| if not any_task_running(task_ids): | |
| return | |
| # Task still active — reset timer and keep waiting | |
| with _conn_lock: | |
| _idle_since = time.monotonic() | |
| time.sleep(0.25) | |
| class PS4Handler(http.server.BaseHTTPRequestHandler): | |
| def setup(self): | |
| super().setup() | |
| if self.client_address[0] == PS4_IP: | |
| global _active_connections, _idle_since, _has_connected | |
| with _conn_lock: | |
| _active_connections += 1 | |
| _idle_since = None | |
| _has_connected = True | |
| def finish(self): | |
| if self.client_address[0] == PS4_IP: | |
| global _active_connections, _idle_since | |
| with _conn_lock: | |
| _active_connections = max(0, _active_connections - 1) | |
| if _active_connections == 0: | |
| _idle_since = time.monotonic() | |
| super().finish() | |
| def do_HEAD(self): | |
| self._handle(head_only=True) | |
| def do_GET(self): | |
| self._handle(head_only=False) | |
| def _handle(self, head_only: bool): | |
| if self.client_address[0] != PS4_IP: | |
| self.send_error(403, "Forbidden") | |
| return | |
| filename = self.path.lstrip("/").split("?")[0] | |
| if filename not in file_map: | |
| self.send_error(404, "Not Found") | |
| return | |
| abs_path = file_map[filename] | |
| f = file_handles[filename] | |
| file_size = os.fstat(f.fileno()).st_size | |
| range_header = self.headers.get("Range", "") | |
| start, end = 0, file_size - 1 | |
| partial = False | |
| if range_header.startswith("bytes="): | |
| parts = range_header[6:].split("-") | |
| start = int(parts[0]) if parts[0] else 0 | |
| end = int(parts[1]) if len(parts) > 1 and parts[1] else file_size - 1 | |
| partial = True | |
| content_length = end - start + 1 | |
| if partial: | |
| self.send_response(206) | |
| self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}") | |
| else: | |
| self.send_response(200) | |
| self.send_header("Content-Type", "application/octet-stream") | |
| self.send_header("Content-Length", str(content_length)) | |
| self.send_header("Accept-Ranges", "bytes") | |
| self.end_headers() | |
| if head_only: | |
| return | |
| try: | |
| self.connection.sendfile(f, offset=start, count=content_length) | |
| except (BrokenPipeError, ConnectionResetError, OSError): | |
| pass | |
| def log_message(self, fmt, *args): | |
| pass | |
| def ps4_post(path: str, payload: dict) -> dict: | |
| data = json.dumps(payload).encode() | |
| req = urllib.request.Request( | |
| f"http://{PS4_IP}:12800{path}", | |
| data=data, | |
| headers={"Content-Type": "application/json"}, | |
| method="POST", | |
| ) | |
| with urllib.request.urlopen(req, timeout=10) as resp: | |
| body = resp.read().decode("utf-8", errors="replace") | |
| # PS4 uses hex literals (0x...) which aren't valid JSON — convert to decimal | |
| body = re.sub(r'\b0x[0-9a-fA-F]+\b', lambda m: str(int(m.group(), 16)), body) | |
| try: | |
| return json.loads(body) | |
| except json.JSONDecodeError: | |
| print(f" [ps4] unexpected response from {path}: {body!r}", flush=True) | |
| return {} | |
| def send_install_request(url: str) -> int | None: | |
| result = ps4_post("/api/install", {"type": "direct", "packages": [url]}) | |
| task_id = result.get("task_id") | |
| title = result.get("title", "") | |
| print(f" [ps4] task {task_id}: {title}", flush=True) | |
| return task_id | |
| def poll_progress(task_id: int, stop: threading.Event) -> None: | |
| print(" (only updated while Remote Package Installer is running)", flush=True) | |
| while not stop.is_set(): | |
| try: | |
| r = ps4_post("/api/get_task_progress", {"task_id": task_id}) | |
| if r: | |
| error = r.get("error", 0) | |
| if error != 0: | |
| label = f"error {error:#x}" | |
| else: | |
| length_total = r.get("length_total", 0) | |
| transferred_total = r.get("transferred_total", 0) | |
| preparing = r.get("preparing_percent", 0) | |
| local_copy = r.get("local_copy_percent", 0) | |
| if length_total > 0: | |
| pct = transferred_total * 100 // length_total | |
| label = f"downloading {pct}% ({transferred_total/1048576:.1f} / {length_total/1048576:.1f} MB)" | |
| elif preparing < 100: | |
| label = f"preparing {preparing}%" | |
| elif local_copy < 100: | |
| label = f"installing {local_copy}%" | |
| else: | |
| label = "done" | |
| print(f"\r [progress] {label:<50}", end="", flush=True) | |
| except Exception: | |
| pass | |
| stop.wait(10.0) | |
| print(flush=True) | |
| def main(): | |
| if len(sys.argv) < 2: | |
| print(f"Usage: {sys.argv[0]} <file.pkg> [file2.pkg ...]") | |
| sys.exit(1) | |
| paths = [os.path.abspath(p) for p in sys.argv[1:]] | |
| for p in paths: | |
| if not os.path.isfile(p): | |
| print(f"Error: not a file: {p}") | |
| sys.exit(1) | |
| for p in paths: | |
| fn = path_to_filename(p) | |
| file_map[fn] = p | |
| file_handles[fn] = open(p, "rb") | |
| if not check_ps4_reachable(): | |
| print(f"Error: cannot reach PS4 at {PS4_IP}:12800.") | |
| print(f"Make sure PS4 is connected with {PS4_IP} and Remote Package Installer is running.") | |
| sys.exit(1) | |
| local_ip = get_local_ip() | |
| server = http.server.ThreadingHTTPServer(("0.0.0.0", SERVE_PORT), PS4Handler) | |
| threading.Thread(target=server.serve_forever, daemon=True).start() | |
| print(f"Serving on {local_ip}:{SERVE_PORT}, accepting connections from {PS4_IP} only", flush=True) | |
| stop_progress = threading.Event() | |
| task_ids: list[int] = [] | |
| for p in paths: | |
| fn = path_to_filename(p) | |
| url = f"http://{local_ip}:{SERVE_PORT}/{fn}" | |
| print(f"\n-> {os.path.basename(p)}", flush=True) | |
| print(f" url : {url}", flush=True) | |
| task_id = send_install_request(url) | |
| if task_id is not None: | |
| task_ids.append(task_id) | |
| threading.Thread( | |
| target=poll_progress, args=(task_id, stop_progress), daemon=True | |
| ).start() | |
| print(f"\nWaiting... (will exit {IDLE_TIMEOUT:.0f}s after last PS4 connection closes)", flush=True) | |
| wait_for_idle(task_ids) | |
| stop_progress.set() | |
| print("Done.", flush=True) | |
| server.shutdown() | |
| for f in file_handles.values(): | |
| f.close() | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment