Skip to content

Instantly share code, notes, and snippets.

@hypafrag
Last active August 20, 2026 17:27
Show Gist options
  • Select an option

  • Save hypafrag/2d415a92e8e2427be6d5e6278ad1bb53 to your computer and use it in GitHub Desktop.

Select an option

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 ...]
#!/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