mirror of
https://github.com/rommapp/romm.git
synced 2026-02-18 00:27:41 +01:00
@@ -61,6 +61,12 @@ class EjsControls(TypedDict):
|
||||
EjsOption = dict[str, str] # option_name -> option_value
|
||||
|
||||
|
||||
class NetplayICEServer(TypedDict):
|
||||
urls: str
|
||||
username: NotRequired[str]
|
||||
credential: NotRequired[str]
|
||||
|
||||
|
||||
class Config:
|
||||
CONFIG_FILE_MOUNTED: bool
|
||||
CONFIG_FILE_WRITABLE: bool
|
||||
@@ -78,6 +84,8 @@ class Config:
|
||||
HIGH_PRIO_STRUCTURE_PATH: str
|
||||
EJS_DEBUG: bool
|
||||
EJS_CACHE_LIMIT: int | None
|
||||
EJS_NETPLAY_ENABLED: bool
|
||||
EJS_NETPLAY_ICE_SERVERS: list[NetplayICEServer]
|
||||
EJS_SETTINGS: dict[str, EjsOption] # core_name -> EjsOption
|
||||
EJS_CONTROLS: dict[str, EjsControls] # core_name -> EjsControls
|
||||
SCAN_METADATA_PRIORITY: list[str]
|
||||
@@ -220,6 +228,12 @@ class ConfigManager:
|
||||
EJS_CACHE_LIMIT=pydash.get(
|
||||
self._raw_config, "emulatorjs.cache_limit", None
|
||||
),
|
||||
EJS_NETPLAY_ENABLED=pydash.get(
|
||||
self._raw_config, "emulatorjs.netplay.enabled", False
|
||||
),
|
||||
EJS_NETPLAY_ICE_SERVERS=pydash.get(
|
||||
self._raw_config, "emulatorjs.netplay.ice_servers", []
|
||||
),
|
||||
EJS_SETTINGS=pydash.get(self._raw_config, "emulatorjs.settings", {}),
|
||||
EJS_CONTROLS=self._get_ejs_controls(),
|
||||
SCAN_METADATA_PRIORITY=pydash.get(
|
||||
@@ -391,6 +405,12 @@ class ConfigManager:
|
||||
log.critical("Invalid config.yml: emulatorjs.debug must be a boolean")
|
||||
sys.exit(3)
|
||||
|
||||
if not isinstance(self.config.EJS_NETPLAY_ENABLED, bool):
|
||||
log.critical(
|
||||
"Invalid config.yml: emulatorjs.netplay.enabled must be a boolean"
|
||||
)
|
||||
sys.exit(3)
|
||||
|
||||
if self.config.EJS_CACHE_LIMIT is not None and not isinstance(
|
||||
self.config.EJS_CACHE_LIMIT, int
|
||||
):
|
||||
@@ -399,6 +419,12 @@ class ConfigManager:
|
||||
)
|
||||
sys.exit(3)
|
||||
|
||||
if not isinstance(self.config.EJS_NETPLAY_ICE_SERVERS, list):
|
||||
log.critical(
|
||||
"Invalid config.yml: emulatorjs.netplay.ice_servers must be a list"
|
||||
)
|
||||
sys.exit(3)
|
||||
|
||||
if not isinstance(self.config.EJS_SETTINGS, dict):
|
||||
log.critical("Invalid config.yml: emulatorjs.settings must be a dictionary")
|
||||
sys.exit(3)
|
||||
@@ -507,6 +533,10 @@ class ConfigManager:
|
||||
"emulatorjs": {
|
||||
"debug": self.config.EJS_DEBUG,
|
||||
"cache_limit": self.config.EJS_CACHE_LIMIT,
|
||||
"netplay": {
|
||||
"enabled": self.config.EJS_NETPLAY_ENABLED,
|
||||
"ice_servers": self.config.EJS_NETPLAY_ICE_SERVERS,
|
||||
},
|
||||
"settings": self.config.EJS_SETTINGS,
|
||||
"controls": self._format_ejs_controls_for_yaml(),
|
||||
},
|
||||
|
||||
@@ -37,6 +37,8 @@ def get_config() -> ConfigResponse:
|
||||
SKIP_HASH_CALCULATION=cfg.SKIP_HASH_CALCULATION,
|
||||
EJS_DEBUG=cfg.EJS_DEBUG,
|
||||
EJS_CACHE_LIMIT=cfg.EJS_CACHE_LIMIT,
|
||||
EJS_NETPLAY_ENABLED=cfg.EJS_NETPLAY_ENABLED,
|
||||
EJS_NETPLAY_ICE_SERVERS=cfg.EJS_NETPLAY_ICE_SERVERS,
|
||||
EJS_CONTROLS=cfg.EJS_CONTROLS,
|
||||
EJS_SETTINGS=cfg.EJS_SETTINGS,
|
||||
SCAN_METADATA_PRIORITY=cfg.SCAN_METADATA_PRIORITY,
|
||||
|
||||
64
backend/endpoints/netplay.py
Normal file
64
backend/endpoints/netplay.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from typing import Dict, TypedDict
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from decorators.auth import protected_route
|
||||
from handler.auth.constants import Scope
|
||||
from handler.netplay_handler import NetplayRoom, netplay_handler
|
||||
from utils.router import APIRouter
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/netplay",
|
||||
tags=["netplay"],
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_MAX_PLAYERS = 4
|
||||
|
||||
|
||||
def _get_owner_player_name(room: NetplayRoom) -> str:
|
||||
owner_sid = room.get("owner")
|
||||
if not owner_sid:
|
||||
return "Unknown"
|
||||
|
||||
return next(
|
||||
(
|
||||
p["player_name"]
|
||||
for p in room["players"].values()
|
||||
if p["socketId"] == owner_sid
|
||||
),
|
||||
"Unknown",
|
||||
)
|
||||
|
||||
|
||||
def _is_room_open(room: NetplayRoom, game_id: str) -> bool:
|
||||
if len(room["players"]) >= room["max_players"]:
|
||||
return False
|
||||
return str(room["game_id"]) == str(game_id)
|
||||
|
||||
|
||||
class RoomsResponse(TypedDict):
|
||||
room_name: str
|
||||
current: int
|
||||
max: int
|
||||
player_name: str
|
||||
hasPassword: bool
|
||||
|
||||
|
||||
@protected_route(router.get, "/list", [Scope.ASSETS_READ])
|
||||
async def get_rooms(request: Request, game_id: str) -> Dict[str, RoomsResponse]:
|
||||
netplay_rooms = await netplay_handler.get_all()
|
||||
|
||||
open_rooms: Dict[str, RoomsResponse] = {
|
||||
session_id: RoomsResponse(
|
||||
room_name=room["room_name"],
|
||||
current=len(room["players"]),
|
||||
max=room["max_players"],
|
||||
player_name=_get_owner_player_name(room),
|
||||
hasPassword=bool(room["password"]),
|
||||
)
|
||||
for session_id, room in netplay_rooms.items()
|
||||
if _is_room_open(room, game_id)
|
||||
}
|
||||
|
||||
return open_rooms
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import TypedDict
|
||||
|
||||
from config.config_manager import EjsControls
|
||||
from config.config_manager import EjsControls, NetplayICEServer
|
||||
|
||||
|
||||
class ConfigResponse(TypedDict):
|
||||
@@ -17,6 +17,8 @@ class ConfigResponse(TypedDict):
|
||||
SKIP_HASH_CALCULATION: bool
|
||||
EJS_DEBUG: bool
|
||||
EJS_CACHE_LIMIT: int | None
|
||||
EJS_NETPLAY_ENABLED: bool
|
||||
EJS_NETPLAY_ICE_SERVERS: list[NetplayICEServer]
|
||||
EJS_SETTINGS: dict[str, dict[str, str]]
|
||||
EJS_CONTROLS: dict[str, EjsControls]
|
||||
SCAN_METADATA_PRIORITY: list[str]
|
||||
|
||||
224
backend/endpoints/sockets/netplay.py
Normal file
224
backend/endpoints/sockets/netplay.py
Normal file
@@ -0,0 +1,224 @@
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
from endpoints.netplay import DEFAULT_MAX_PLAYERS
|
||||
from handler.netplay_handler import NetplayPlayerInfo, NetplayRoom, netplay_handler
|
||||
from handler.socket_handler import netplay_socket_handler
|
||||
|
||||
|
||||
class RoomDataExtra(TypedDict):
|
||||
sessionid: str | None
|
||||
userid: str | None
|
||||
playerId: str | None
|
||||
room_name: NotRequired[str]
|
||||
game_id: NotRequired[str]
|
||||
domain: NotRequired[str]
|
||||
player_name: NotRequired[str]
|
||||
room_password: NotRequired[str]
|
||||
|
||||
|
||||
class RoomData(TypedDict):
|
||||
extra: RoomDataExtra
|
||||
maxPlayers: NotRequired[int]
|
||||
|
||||
|
||||
@netplay_socket_handler.socket_server.on("open-room") # type: ignore
|
||||
async def open_room(sid: str, data: RoomData):
|
||||
extra_data = data["extra"]
|
||||
|
||||
session_id = extra_data.get("sessionid")
|
||||
player_id = extra_data.get("userid") or extra_data.get("playerId")
|
||||
|
||||
if not session_id or not player_id:
|
||||
return "Invalid data: sessionId and playerId required"
|
||||
|
||||
if await netplay_handler.get(session_id):
|
||||
return "Room already exists"
|
||||
|
||||
new_room = NetplayRoom(
|
||||
owner=sid,
|
||||
players={
|
||||
player_id: NetplayPlayerInfo(
|
||||
socketId=sid,
|
||||
player_name=extra_data.get("player_name") or f"Player {player_id}",
|
||||
userid=extra_data.get("userid"),
|
||||
playerId=extra_data.get("playerId"),
|
||||
)
|
||||
},
|
||||
peers=[],
|
||||
room_name=extra_data.get("room_name") or f"Room {session_id}",
|
||||
game_id=extra_data.get("game_id") or "default",
|
||||
domain=extra_data.get("domain", None),
|
||||
password=extra_data.get("room_password", None),
|
||||
max_players=data.get("maxPlayers") or DEFAULT_MAX_PLAYERS,
|
||||
)
|
||||
await netplay_handler.set(session_id, new_room)
|
||||
|
||||
await netplay_socket_handler.socket_server.enter_room(sid, session_id)
|
||||
await netplay_socket_handler.socket_server.save_session(
|
||||
sid,
|
||||
{
|
||||
"session_id": session_id,
|
||||
"player_id": player_id,
|
||||
},
|
||||
)
|
||||
await netplay_socket_handler.socket_server.emit(
|
||||
"users-updated", new_room["players"], room=session_id
|
||||
)
|
||||
|
||||
|
||||
@netplay_socket_handler.socket_server.on("join-room") # type: ignore
|
||||
async def join_room(sid: str, data: RoomData):
|
||||
extra_data = data["extra"]
|
||||
|
||||
session_id = extra_data.get("sessionid")
|
||||
player_id = extra_data.get("userid") or extra_data.get("playerId")
|
||||
|
||||
if not session_id or not player_id:
|
||||
return "Invalid data: sessionId and playerId required"
|
||||
|
||||
current_room = await netplay_handler.get(session_id)
|
||||
if not current_room:
|
||||
return "Room not found"
|
||||
|
||||
if current_room["password"] and current_room["password"] != extra_data.get(
|
||||
"room_password"
|
||||
):
|
||||
return "Incorrect password"
|
||||
|
||||
if len(current_room["players"].keys()) >= current_room["max_players"]:
|
||||
return "Room is full"
|
||||
|
||||
current_room["players"][player_id] = NetplayPlayerInfo(
|
||||
socketId=sid,
|
||||
player_name=extra_data.get("player_name") or f"Player {player_id}",
|
||||
userid=extra_data.get("userid"),
|
||||
playerId=extra_data.get("playerId"),
|
||||
)
|
||||
await netplay_handler.set(session_id, current_room)
|
||||
|
||||
await netplay_socket_handler.socket_server.enter_room(sid, session_id)
|
||||
await netplay_socket_handler.socket_server.save_session(
|
||||
sid,
|
||||
{
|
||||
"session_id": session_id,
|
||||
"player_id": player_id,
|
||||
},
|
||||
)
|
||||
await netplay_socket_handler.socket_server.emit(
|
||||
"users-updated", current_room["players"], room=session_id
|
||||
)
|
||||
|
||||
return None, current_room["players"]
|
||||
|
||||
|
||||
async def _handle_leave(sid: str, session_id: str, player_id: str):
|
||||
current_room = await netplay_handler.get(session_id)
|
||||
if not current_room:
|
||||
return
|
||||
|
||||
current_room["players"].pop(player_id, None)
|
||||
|
||||
if not current_room["players"]:
|
||||
await netplay_handler.delete([session_id])
|
||||
# Notify clients that the room is now empty
|
||||
await netplay_socket_handler.socket_server.emit(
|
||||
"users-updated", {}, room=session_id
|
||||
)
|
||||
return
|
||||
|
||||
if sid == current_room["owner"]:
|
||||
# Owner left, assign a new one
|
||||
remaining_players = list(current_room["players"].values())
|
||||
if remaining_players:
|
||||
current_room["owner"] = remaining_players[0]["socketId"]
|
||||
|
||||
await netplay_handler.set(session_id, current_room)
|
||||
await netplay_socket_handler.socket_server.emit(
|
||||
"users-updated", current_room["players"], room=session_id
|
||||
)
|
||||
|
||||
|
||||
@netplay_socket_handler.socket_server.on("leave-room") # type: ignore
|
||||
async def leave_room(sid: str):
|
||||
stored_session = await netplay_socket_handler.socket_server.get_session(sid)
|
||||
session_id = stored_session.get("session_id")
|
||||
player_id = stored_session.get("player_id")
|
||||
|
||||
if session_id and player_id:
|
||||
await _handle_leave(sid, session_id, player_id)
|
||||
await netplay_socket_handler.socket_server.leave_room(sid, session_id)
|
||||
|
||||
|
||||
class WebRTCSignalData(TypedDict, total=False):
|
||||
target: str
|
||||
candidate: Any
|
||||
offer: Any
|
||||
answer: Any
|
||||
requestRenegotiate: bool
|
||||
|
||||
|
||||
@netplay_socket_handler.socket_server.on("webrtc-signal") # type: ignore
|
||||
async def webrtc_signal(sid: str, data: WebRTCSignalData):
|
||||
target = data.get("target")
|
||||
request_renegotiate = data.get("requestRenegotiate", False)
|
||||
|
||||
if request_renegotiate:
|
||||
if not target:
|
||||
return
|
||||
await netplay_socket_handler.socket_server.emit(
|
||||
"webrtc-signal",
|
||||
{"sender": sid, "requestRenegotiate": True},
|
||||
to=target,
|
||||
)
|
||||
else:
|
||||
if not target:
|
||||
return # drop message—no recipient
|
||||
await netplay_socket_handler.socket_server.emit(
|
||||
"webrtc-signal",
|
||||
{
|
||||
"sender": sid,
|
||||
"candidate": data.get("candidate"),
|
||||
"offer": data.get("offer"),
|
||||
"answer": data.get("answer"),
|
||||
},
|
||||
to=target,
|
||||
)
|
||||
|
||||
|
||||
@netplay_socket_handler.socket_server.on("webrtc-signal-error") # type: ignore
|
||||
async def webrtc_signal_error(_sid: str, _error: str, _data: Any):
|
||||
pass
|
||||
|
||||
|
||||
@netplay_socket_handler.socket_server.on("disconnect") # type: ignore
|
||||
async def disconnect(sid: str):
|
||||
stored_session = await netplay_socket_handler.socket_server.get_session(sid)
|
||||
session_id = stored_session.get("session_id")
|
||||
player_id = stored_session.get("player_id")
|
||||
|
||||
if session_id and player_id:
|
||||
await _handle_leave(sid, session_id, player_id)
|
||||
|
||||
|
||||
async def _broadcast_to_room(sid: str, event: str, data: Any):
|
||||
stored_session = await netplay_socket_handler.socket_server.get_session(sid)
|
||||
session_id = stored_session.get("session_id")
|
||||
if session_id:
|
||||
await netplay_socket_handler.socket_server.emit(
|
||||
event, data, room=session_id, skip_sid=sid
|
||||
)
|
||||
|
||||
|
||||
@netplay_socket_handler.socket_server.on("data-message") # type: ignore
|
||||
async def data_message(sid: str, data: Any):
|
||||
await _broadcast_to_room(sid, "data-message", data)
|
||||
|
||||
|
||||
@netplay_socket_handler.socket_server.on("snapshot") # type: ignore
|
||||
async def snapshot(sid: str, data: Any):
|
||||
await _broadcast_to_room(sid, "snapshot", data)
|
||||
|
||||
|
||||
@netplay_socket_handler.socket_server.on("input") # type: ignore
|
||||
async def input(sid: str, data: Any):
|
||||
await _broadcast_to_room(sid, "input", data)
|
||||
50
backend/handler/netplay_handler.py
Normal file
50
backend/handler/netplay_handler.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import json
|
||||
from typing import Optional, TypedDict
|
||||
|
||||
from handler.redis_handler import async_cache
|
||||
|
||||
|
||||
class NetplayPlayerInfo(TypedDict):
|
||||
socketId: str
|
||||
player_name: str
|
||||
userid: str | None
|
||||
playerId: str | None
|
||||
|
||||
|
||||
class NetplayRoom(TypedDict):
|
||||
owner: str
|
||||
players: dict[str, NetplayPlayerInfo]
|
||||
peers: list[str]
|
||||
room_name: str
|
||||
game_id: str
|
||||
domain: Optional[str]
|
||||
password: Optional[str]
|
||||
max_players: int
|
||||
|
||||
|
||||
class NetplayHandler:
|
||||
"""A class to handle netplay rooms in Redis."""
|
||||
|
||||
def __init__(self):
|
||||
self.hash_name = "netplay:rooms"
|
||||
|
||||
async def get(self, room_id: str) -> NetplayRoom | None:
|
||||
"""Get a room from Redis."""
|
||||
room = await async_cache.hget(self.hash_name, room_id)
|
||||
return json.loads(room) if room else None
|
||||
|
||||
async def set(self, room_id: str, room_data: NetplayRoom):
|
||||
"""Set a room in Redis."""
|
||||
return await async_cache.hset(self.hash_name, room_id, json.dumps(room_data))
|
||||
|
||||
async def delete(self, room_ids: list[str]):
|
||||
"""Delete a room from Redis."""
|
||||
return await async_cache.hdel(self.hash_name, *room_ids)
|
||||
|
||||
async def get_all(self) -> dict[str, NetplayRoom]:
|
||||
"""Get all rooms from Redis."""
|
||||
rooms = await async_cache.hgetall(self.hash_name)
|
||||
return {room_id: json.loads(room_data) for room_id, room_data in rooms.items()}
|
||||
|
||||
|
||||
netplay_handler = NetplayHandler()
|
||||
@@ -5,7 +5,7 @@ from utils import json_module
|
||||
|
||||
|
||||
class SocketHandler:
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, path: str) -> None:
|
||||
self.socket_server = socketio.AsyncServer(
|
||||
cors_allowed_origins="*",
|
||||
async_mode="asgi",
|
||||
@@ -19,9 +19,8 @@ class SocketHandler:
|
||||
cors_credentials=True,
|
||||
)
|
||||
|
||||
self.socket_app = socketio.ASGIApp(
|
||||
self.socket_server, socketio_path="/ws/socket.io"
|
||||
)
|
||||
self.socket_app = socketio.ASGIApp(self.socket_server, socketio_path=path)
|
||||
|
||||
|
||||
socket_handler = SocketHandler()
|
||||
socket_handler = SocketHandler(path="/ws/socket.io")
|
||||
netplay_socket_handler = SocketHandler(path="/netplay/socket.io")
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi_pagination import add_pagination
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
from startup import main
|
||||
|
||||
import endpoints.sockets.netplay # noqa
|
||||
import endpoints.sockets.scan # noqa
|
||||
from config import (
|
||||
DEV_HOST,
|
||||
@@ -31,6 +32,7 @@ from endpoints import (
|
||||
firmware,
|
||||
gamelist,
|
||||
heartbeat,
|
||||
netplay,
|
||||
platform,
|
||||
raw,
|
||||
rom,
|
||||
@@ -45,7 +47,7 @@ from endpoints import (
|
||||
from handler.auth.hybrid_auth import HybridAuthBackend
|
||||
from handler.auth.middleware.csrf_middleware import CSRFMiddleware
|
||||
from handler.auth.middleware.redis_session_middleware import RedisSessionMiddleware
|
||||
from handler.socket_handler import socket_handler
|
||||
from handler.socket_handler import netplay_socket_handler, socket_handler
|
||||
from logger.formatter import LOGGING_CONFIG
|
||||
from utils import get_version
|
||||
from utils.context import (
|
||||
@@ -93,7 +95,11 @@ if not IS_PYTEST_RUN and not DISABLE_CSRF_PROTECTION:
|
||||
CSRFMiddleware,
|
||||
cookie_name="romm_csrftoken",
|
||||
secret=ROMM_AUTH_SECRET_KEY,
|
||||
exempt_urls=[re.compile(r"^/api/token.*"), re.compile(r"^/ws")],
|
||||
exempt_urls=[
|
||||
re.compile(r"^/api/token.*"),
|
||||
re.compile(r"^/ws"),
|
||||
re.compile(r"^/netplay"),
|
||||
],
|
||||
)
|
||||
|
||||
# Handles both basic and oauth authentication
|
||||
@@ -130,8 +136,10 @@ app.include_router(screenshots.router, prefix="/api")
|
||||
app.include_router(firmware.router, prefix="/api")
|
||||
app.include_router(collections.router, prefix="/api")
|
||||
app.include_router(gamelist.router, prefix="/api")
|
||||
app.include_router(netplay.router, prefix="/api")
|
||||
|
||||
app.mount("/ws", socket_handler.socket_app)
|
||||
app.mount("/netplay", netplay_socket_handler.socket_app)
|
||||
|
||||
add_pagination(app)
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ from handler.metadata.base_handler import (
|
||||
from handler.redis_handler import async_cache
|
||||
from logger.logger import log
|
||||
from models.firmware import FIRMWARE_FIXTURES_DIR, KNOWN_BIOS_KEY
|
||||
from tasks.scheduled.cleanup_netplay import cleanup_netplay_task
|
||||
from tasks.scheduled.convert_images_to_webp import convert_images_to_webp_task
|
||||
from tasks.scheduled.scan_library import scan_library_task
|
||||
from tasks.scheduled.sync_retroachievements_progress import (
|
||||
@@ -46,6 +47,8 @@ async def main() -> None:
|
||||
log.info("Running startup tasks")
|
||||
|
||||
# Initialize scheduled tasks
|
||||
cleanup_netplay_task.init()
|
||||
|
||||
if ENABLE_SCHEDULED_RESCAN:
|
||||
log.info("Starting scheduled rescan")
|
||||
scan_library_task.init()
|
||||
|
||||
32
backend/tasks/scheduled/cleanup_netplay.py
Normal file
32
backend/tasks/scheduled/cleanup_netplay.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from handler.netplay_handler import netplay_handler
|
||||
from logger.logger import log
|
||||
from tasks.tasks import PeriodicTask, TaskType
|
||||
|
||||
|
||||
class CleanupNetplayTask(PeriodicTask):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
title="Scheduled netplay cleanup",
|
||||
description="Cleans up empty netplay rooms",
|
||||
task_type=TaskType.CLEANUP,
|
||||
enabled=True,
|
||||
manual_run=False,
|
||||
cron_string="*/5 * * * *", # Every 5 minutes
|
||||
func="tasks.scheduled.cleanup_netplay.cleanup_netplay_task.run",
|
||||
)
|
||||
|
||||
async def run(self) -> None:
|
||||
if not self.enabled:
|
||||
self.unschedule()
|
||||
return
|
||||
|
||||
netplay_rooms = await netplay_handler.get_all()
|
||||
rooms_to_delete = [
|
||||
sid for sid, r in netplay_rooms.items() if len(r.get("players", {})) == 0
|
||||
]
|
||||
if rooms_to_delete:
|
||||
log.info(f"Cleaning up {len(rooms_to_delete)} empty netplay rooms")
|
||||
await netplay_handler.delete(rooms_to_delete)
|
||||
|
||||
|
||||
cleanup_netplay_task = CleanupNetplayTask()
|
||||
@@ -55,6 +55,13 @@ scan:
|
||||
emulatorjs:
|
||||
debug: true
|
||||
cache_limit: 1000
|
||||
netplay:
|
||||
enabled: true
|
||||
ice_servers:
|
||||
- urls: "stun:stun.relay.metered.ca:80"
|
||||
- urls: "turn:global.relay.metered.ca:80"
|
||||
username: "user"
|
||||
credential: "password"
|
||||
settings:
|
||||
parallel_n64:
|
||||
vsync: disabled
|
||||
|
||||
@@ -22,6 +22,15 @@ def test_config_loader():
|
||||
assert loader.config.SKIP_HASH_CALCULATION
|
||||
assert loader.config.EJS_DEBUG
|
||||
assert loader.config.EJS_CACHE_LIMIT == 1000
|
||||
assert loader.config.EJS_NETPLAY_ENABLED
|
||||
assert loader.config.EJS_NETPLAY_ICE_SERVERS == [
|
||||
{"urls": "stun:stun.relay.metered.ca:80"},
|
||||
{
|
||||
"urls": "turn:global.relay.metered.ca:80",
|
||||
"username": "user",
|
||||
"credential": "password",
|
||||
},
|
||||
]
|
||||
assert loader.config.EJS_SETTINGS == {
|
||||
"parallel_n64": {"vsync": "disabled"},
|
||||
"snes9x": {"snes9x_region": "ntsc"},
|
||||
@@ -60,5 +69,7 @@ def test_empty_config_loader():
|
||||
assert not loader.config.SKIP_HASH_CALCULATION
|
||||
assert not loader.config.EJS_DEBUG
|
||||
assert loader.config.EJS_CACHE_LIMIT is None
|
||||
assert not loader.config.EJS_NETPLAY_ENABLED
|
||||
assert loader.config.EJS_NETPLAY_ICE_SERVERS == []
|
||||
assert loader.config.EJS_SETTINGS == {}
|
||||
assert loader.config.EJS_CONTROLS == {}
|
||||
|
||||
@@ -57,7 +57,7 @@ server {
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
}
|
||||
location /ws {
|
||||
location ~ ^/(ws|netplay) {
|
||||
proxy_pass http://wsgi_server;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
|
||||
@@ -115,6 +115,13 @@ filesystem: {} # { roms_folder: 'roms' } For example if your folder structure is
|
||||
# snes9x_region: ntsc
|
||||
# default: # These settings apply to all cores
|
||||
# fps: show
|
||||
# netplay:
|
||||
# enabled: true
|
||||
# ice_servers:
|
||||
# - urls: "stun:stun.relay.metered.ca:80"
|
||||
# - urls: "turn:global.relay.metered.ca:80"
|
||||
# username: "<username>"
|
||||
# credential: "<password>"
|
||||
# controls: # https://emulatorjs.org/docs4devs/control-mapping/
|
||||
# snes9x:
|
||||
# 0: # Player 1
|
||||
|
||||
2
frontend/src/__generated__/index.ts
generated
2
frontend/src/__generated__/index.ts
generated
@@ -51,6 +51,7 @@ export type { JobStatus } from './models/JobStatus';
|
||||
export type { LaunchboxImage } from './models/LaunchboxImage';
|
||||
export type { MetadataSourcesDict } from './models/MetadataSourcesDict';
|
||||
export type { MobyMetadataPlatform } from './models/MobyMetadataPlatform';
|
||||
export type { NetplayICEServer } from './models/NetplayICEServer';
|
||||
export type { OIDCDict } from './models/OIDCDict';
|
||||
export type { PlatformSchema } from './models/PlatformSchema';
|
||||
export type { RAGameRomAchievement } from './models/RAGameRomAchievement';
|
||||
@@ -71,6 +72,7 @@ export type { RomRAMetadata } from './models/RomRAMetadata';
|
||||
export type { RomSSMetadata } from './models/RomSSMetadata';
|
||||
export type { RomUserSchema } from './models/RomUserSchema';
|
||||
export type { RomUserStatus } from './models/RomUserStatus';
|
||||
export type { RoomsResponse } from './models/RoomsResponse';
|
||||
export type { SaveSchema } from './models/SaveSchema';
|
||||
export type { ScanStats } from './models/ScanStats';
|
||||
export type { ScanTaskMeta } from './models/ScanTaskMeta';
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
import type { EjsControls } from './EjsControls';
|
||||
import type { NetplayICEServer } from './NetplayICEServer';
|
||||
export type ConfigResponse = {
|
||||
CONFIG_FILE_MOUNTED: boolean;
|
||||
CONFIG_FILE_WRITABLE: boolean;
|
||||
@@ -17,6 +18,8 @@ export type ConfigResponse = {
|
||||
SKIP_HASH_CALCULATION: boolean;
|
||||
EJS_DEBUG: boolean;
|
||||
EJS_CACHE_LIMIT: (number | null);
|
||||
EJS_NETPLAY_ENABLED: boolean;
|
||||
EJS_NETPLAY_ICE_SERVERS: Array<NetplayICEServer>;
|
||||
EJS_SETTINGS: Record<string, Record<string, string>>;
|
||||
EJS_CONTROLS: Record<string, EjsControls>;
|
||||
SCAN_METADATA_PRIORITY: Array<string>;
|
||||
|
||||
@@ -84,8 +84,8 @@ export type DetailedRomSchema = {
|
||||
missing_from_fs: boolean;
|
||||
siblings: Array<SiblingRomSchema>;
|
||||
rom_user: RomUserSchema;
|
||||
merged_ra_metadata: (RomRAMetadata | null);
|
||||
merged_screenshots: Array<string>;
|
||||
merged_ra_metadata: (RomRAMetadata | null);
|
||||
user_saves: Array<SaveSchema>;
|
||||
user_states: Array<StateSchema>;
|
||||
user_screenshots: Array<ScreenshotSchema>;
|
||||
|
||||
10
frontend/src/__generated__/models/NetplayICEServer.ts
generated
Normal file
10
frontend/src/__generated__/models/NetplayICEServer.ts
generated
Normal file
@@ -0,0 +1,10 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type NetplayICEServer = {
|
||||
urls: string;
|
||||
username?: string;
|
||||
credential?: string;
|
||||
};
|
||||
|
||||
12
frontend/src/__generated__/models/RoomsResponse.ts
generated
Normal file
12
frontend/src/__generated__/models/RoomsResponse.ts
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
/* generated using openapi-typescript-codegen -- do not edit */
|
||||
/* istanbul ignore file */
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export type RoomsResponse = {
|
||||
room_name: string;
|
||||
current: number;
|
||||
max: number;
|
||||
player_name: string;
|
||||
hasPassword: boolean;
|
||||
};
|
||||
|
||||
@@ -79,7 +79,7 @@ export type SimpleRomSchema = {
|
||||
missing_from_fs: boolean;
|
||||
siblings: Array<SiblingRomSchema>;
|
||||
rom_user: RomUserSchema;
|
||||
merged_ra_metadata: (RomRAMetadata | null);
|
||||
merged_screenshots: Array<string>;
|
||||
merged_ra_metadata: (RomRAMetadata | null);
|
||||
};
|
||||
|
||||
|
||||
@@ -607,9 +607,10 @@ async function boot() {
|
||||
// Allow route transition animation to settle
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const EMULATORJS_VERSION = "4.2.3";
|
||||
const LOCAL_PATH = "/assets/emulatorjs/data/";
|
||||
const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data/`;
|
||||
const { EJS_NETPLAY_ENABLED } = configStore.config;
|
||||
const EMULATORJS_VERSION = EJS_NETPLAY_ENABLED ? "nightly" : "4.2.3";
|
||||
const LOCAL_PATH = "/assets/emulatorjs/data";
|
||||
const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data`;
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -622,18 +623,20 @@ async function boot() {
|
||||
});
|
||||
}
|
||||
|
||||
async function attemptLoad(path: string, label: "local" | "cdn") {
|
||||
async function attemptLoad(label: "local" | "cdn") {
|
||||
const path = label === "local" ? LOCAL_PATH : CDN_PATH;
|
||||
loaderStatus.value = label === "local" ? "loading-local" : "loading-cdn";
|
||||
|
||||
window.EJS_pathtodata = path;
|
||||
await loadScript(`${path}loader.js`);
|
||||
await loadScript(`${path}/loader.js`);
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
await attemptLoad(LOCAL_PATH, "local");
|
||||
await attemptLoad(EJS_NETPLAY_ENABLED ? "cdn" : "local");
|
||||
} catch (e) {
|
||||
console.warn("[Play] Local loader failed, trying CDN", e);
|
||||
await attemptLoad(CDN_PATH, "cdn");
|
||||
await attemptLoad(EJS_NETPLAY_ENABLED ? "local" : "cdn");
|
||||
}
|
||||
// Wait for emulator bootstrap
|
||||
const startDeadline = Date.now() + 8000; // 8s
|
||||
|
||||
@@ -24,7 +24,9 @@ const defaultConfig = {
|
||||
PLATFORMS_VERSIONS: {},
|
||||
SKIP_HASH_CALCULATION: false,
|
||||
EJS_DEBUG: false,
|
||||
EJS_NETPLAY_ENABLED: false,
|
||||
EJS_CACHE_LIMIT: null,
|
||||
EJS_NETPLAY_ICE_SERVERS: [],
|
||||
EJS_SETTINGS: {},
|
||||
EJS_CONTROLS: {},
|
||||
SCAN_METADATA_PRIORITY: [],
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ROUTES } from "@/plugins/router";
|
||||
import firmwareApi from "@/services/api/firmware";
|
||||
import romApi from "@/services/api/rom";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import storeConfig from "@/stores/config";
|
||||
import storePlaying from "@/stores/playing";
|
||||
import { type DetailedRom } from "@/stores/roms";
|
||||
import { formatTimestamp, getSupportedEJSCores } from "@/utils";
|
||||
@@ -21,13 +22,12 @@ import { getEmptyCoverImage } from "@/utils/covers";
|
||||
import CacheDialog from "@/views/Player/EmulatorJS/CacheDialog.vue";
|
||||
import Player from "@/views/Player/EmulatorJS/Player.vue";
|
||||
|
||||
const EMULATORJS_VERSION = "4.2.3";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { smAndDown } = useDisplay();
|
||||
const route = useRoute();
|
||||
const auth = storeAuth();
|
||||
const playingStore = storePlaying();
|
||||
const configStore = storeConfig();
|
||||
const { playing, fullScreen } = storeToRefs(playingStore);
|
||||
const rom = ref<DetailedRom | null>(null);
|
||||
const firmwareOptions = ref<FirmwareSchema[]>([]);
|
||||
@@ -42,7 +42,7 @@ const supportedCores = ref<string[]>([]);
|
||||
const gameRunning = ref(false);
|
||||
const fullScreenOnPlay = useLocalStorage("emulation.fullScreenOnPlay", true);
|
||||
|
||||
function onPlay() {
|
||||
async function onPlay() {
|
||||
if (rom.value && auth.scopes.includes("roms.user.write")) {
|
||||
romApi.updateUserRomProps({
|
||||
romId: rom.value.id,
|
||||
@@ -56,33 +56,39 @@ function onPlay() {
|
||||
fullScreen.value = fullScreenOnPlay.value;
|
||||
playing.value = true;
|
||||
|
||||
const LOCAL_PATH = "/assets/emulatorjs/data/";
|
||||
const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data/`;
|
||||
const { EJS_NETPLAY_ENABLED } = configStore.config;
|
||||
const EMULATORJS_VERSION = EJS_NETPLAY_ENABLED ? "nightly" : "4.2.3";
|
||||
const LOCAL_PATH = "/assets/emulatorjs/data";
|
||||
const CDN_PATH = `https://cdn.emulatorjs.org/${EMULATORJS_VERSION}/data`;
|
||||
|
||||
// Try loading local loader.js via fetch to validate it's real JS
|
||||
fetch(`${LOCAL_PATH}loader.js`)
|
||||
.then((res) => {
|
||||
const type = res.headers.get("content-type") || "";
|
||||
if (!res.ok || !type.includes("javascript")) {
|
||||
throw new Error("Invalid local loader.js");
|
||||
}
|
||||
window.EJS_pathtodata = LOCAL_PATH;
|
||||
return res.text();
|
||||
})
|
||||
.then((jsCode) => {
|
||||
playing.value = true;
|
||||
fullScreen.value = fullScreenOnPlay.value;
|
||||
const script = document.createElement("script");
|
||||
script.textContent = jsCode;
|
||||
document.body.appendChild(script);
|
||||
})
|
||||
.catch(() => {
|
||||
console.warn("Local EmulatorJS failed, falling back to CDN");
|
||||
window.EJS_pathtodata = CDN_PATH;
|
||||
const fallbackScript = document.createElement("script");
|
||||
fallbackScript.src = `${CDN_PATH}loader.js`;
|
||||
document.body.appendChild(fallbackScript);
|
||||
function loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement("script");
|
||||
s.src = src;
|
||||
s.async = true;
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error("Failed loading " + src));
|
||||
document.body.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
async function attemptLoad(path: string) {
|
||||
window.EJS_pathtodata = path;
|
||||
await loadScript(`${path}/loader.js`);
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
await attemptLoad(EJS_NETPLAY_ENABLED ? CDN_PATH : LOCAL_PATH);
|
||||
} catch (e) {
|
||||
console.warn("[Play] Local loader failed, trying CDN", e);
|
||||
await attemptLoad(EJS_NETPLAY_ENABLED ? LOCAL_PATH : CDN_PATH);
|
||||
}
|
||||
playing.value = true;
|
||||
fullScreen.value = fullScreenOnPlay.value;
|
||||
} catch (err) {
|
||||
console.error("[Play] Emulator load failure:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function onFullScreenChange() {
|
||||
|
||||
@@ -4,7 +4,12 @@ import { storeToRefs } from "pinia";
|
||||
import { inject, onBeforeUnmount, onMounted, onUnmounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useTheme } from "vuetify";
|
||||
import type { FirmwareSchema, SaveSchema, StateSchema } from "@/__generated__";
|
||||
import type {
|
||||
FirmwareSchema,
|
||||
SaveSchema,
|
||||
StateSchema,
|
||||
NetplayICEServer,
|
||||
} from "@/__generated__";
|
||||
import { ROUTES } from "@/plugins/router";
|
||||
import { saveApi as api } from "@/services/api/save";
|
||||
import storeConfig from "@/stores/config";
|
||||
@@ -69,6 +74,7 @@ declare global {
|
||||
EJS_gameParentUrl: string;
|
||||
EJS_gamePatchUrl: string;
|
||||
EJS_netplayServer: string;
|
||||
EJS_netplayICEServers: NetplayICEServer[];
|
||||
EJS_alignStartButton: "top" | "center" | "bottom";
|
||||
EJS_startOnLoaded: boolean;
|
||||
EJS_fullscreenOnLoaded: boolean;
|
||||
@@ -138,7 +144,16 @@ window.EJS_gameName = romRef.value.fs_name_no_tags
|
||||
window.EJS_language = selectedLanguage.value.value.replace("_", "-");
|
||||
window.EJS_disableAutoLang = true;
|
||||
|
||||
const { EJS_DEBUG, EJS_CACHE_LIMIT } = configStore.config;
|
||||
const {
|
||||
EJS_DEBUG,
|
||||
EJS_CACHE_LIMIT,
|
||||
EJS_NETPLAY_ICE_SERVERS,
|
||||
EJS_NETPLAY_ENABLED,
|
||||
} = configStore.config;
|
||||
window.EJS_netplayServer = EJS_NETPLAY_ENABLED ? window.location.host : "";
|
||||
window.EJS_netplayICEServers = EJS_NETPLAY_ENABLED
|
||||
? EJS_NETPLAY_ICE_SERVERS
|
||||
: [];
|
||||
if (EJS_CACHE_LIMIT !== null) window.EJS_CacheLimit = EJS_CACHE_LIMIT;
|
||||
window.EJS_DEBUG_XX = EJS_DEBUG;
|
||||
|
||||
@@ -374,6 +389,28 @@ window.EJS_onGameStart = async () => {
|
||||
romsStore.update(romRef.value);
|
||||
immediateExit();
|
||||
});
|
||||
|
||||
// The netplay implementation is finnicky, these overrides make it work
|
||||
const { defineNetplayFunctions } = window.EJS_emulator;
|
||||
window.EJS_emulator.defineNetplayFunctions = () => {
|
||||
defineNetplayFunctions.bind(window.EJS_emulator)();
|
||||
|
||||
window.EJS_emulator.netplay.url = {
|
||||
path: "/netplay/socket.io",
|
||||
};
|
||||
|
||||
window.EJS_emulator.netplayGetOpenRooms = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/netplay/list?game_id=${window.EJS_gameID}`,
|
||||
);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Error fetching open rooms:", error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function immediateExit() {
|
||||
|
||||
@@ -123,7 +123,7 @@ export default defineConfig(({ mode }) => {
|
||||
changeOrigin: false,
|
||||
secure: false,
|
||||
},
|
||||
"/ws": {
|
||||
"^/(?:ws|netplay)": {
|
||||
target: `http://127.0.0.1:${backendPort}`,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
|
||||
Reference in New Issue
Block a user