diff --git a/jam_session/server.py b/jam_session/server.py index 964dba6..c747bd7 100644 --- a/jam_session/server.py +++ b/jam_session/server.py @@ -1,6 +1,8 @@ import argparse import asyncio import io +import json +import os import re import secrets import socket @@ -20,6 +22,13 @@ _event_loop: asyncio.AbstractEventLoop | None = None server_host: str = "0.0.0.0" server_port: int = 8090 +SETTINGS_PATH = Path.home() / ".config" / "jam-session" / "settings.json" +settings_config: dict = { + "skip_threshold_pct": 50, + "auto_persist": False, + "skip_cooldown_seconds": 3, +} + def detect_lan_ip() -> str | None: try: @@ -76,6 +85,29 @@ def stop_mdns(): _mdns_instance = None +def _load_settings() -> dict: + defaults = { + "skip_threshold_pct": 50, + "auto_persist": False, + "skip_cooldown_seconds": 3, + } + if SETTINGS_PATH.exists(): + try: + data = json.loads(SETTINGS_PATH.read_text()) + return {**defaults, **data} + except (json.JSONDecodeError, OSError): + pass + _save_settings(defaults) + return defaults + + +def _save_settings(config: dict) -> None: + SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = SETTINGS_PATH.with_suffix(".json.tmp") + tmp.write_text(json.dumps(config, indent=2)) + os.replace(tmp, SETTINGS_PATH) + + cache = AudioCacheManager(max_size_gb=1.0) session = SessionManager() manager = ConnectionManager() @@ -106,7 +138,7 @@ async def _handle_track_finished(): session._sort_by_votes() session.reset_skip_votes() - session.start_skip_cooldown(3) + session.start_skip_cooldown(settings_config["skip_cooldown_seconds"]) if next_track is not None: music.play_track(next_track) @@ -201,7 +233,7 @@ async def handle_message(msg: dict, conn_id: str): ) return - threshold_pct = 50 + threshold_pct = settings_config["skip_threshold_pct"] now = asyncio.get_running_loop().time() if now < session._skip_cooldown_until: await manager.send_error( @@ -217,6 +249,51 @@ async def handle_message(msg: dict, conn_id: str): await manager.broadcast_skip_update(state) + elif msg_type == "client.host.settings": + if conn_id != session.get_host_conn_id(): + await manager.send_error( + manager.active_connections.get(conn_id), + "Only the host can change settings" + ) + return + + payload = msg.get("payload", {}) + new_threshold = payload.get("skip_threshold_pct") + new_auto_persist = payload.get("auto_persist") + new_cooldown = payload.get("skip_cooldown_seconds") + + if new_threshold is not None: + if not isinstance(new_threshold, (int, float)) or new_threshold < 10 or new_threshold > 100: + await manager.send_error( + manager.active_connections.get(conn_id), + "Skip threshold must be between 10 and 100" + ) + return + settings_config["skip_threshold_pct"] = int(new_threshold) + + if new_auto_persist is not None: + if not isinstance(new_auto_persist, bool): + await manager.send_error( + manager.active_connections.get(conn_id), + "auto_persist must be true or false" + ) + return + settings_config["auto_persist"] = new_auto_persist + + if new_cooldown is not None: + if not isinstance(new_cooldown, (int, float)) or new_cooldown < 0 or new_cooldown > 10: + await manager.send_error( + manager.active_connections.get(conn_id), + "Skip cooldown must be between 0 and 10" + ) + return + settings_config["skip_cooldown_seconds"] = int(new_cooldown) + + loop = asyncio.get_running_loop() + loop.run_in_executor(None, _save_settings, dict(settings_config)) + + await manager.broadcast_settings_update(settings_config) + elif msg_type == "client.vote": payload = msg.get("payload", {}) track_id = payload.get("track_id") @@ -396,6 +473,14 @@ async def lifespan(app: FastAPI): host_token = secrets.token_urlsafe(32) session.set_host_token(host_token) + global settings_config + settings_config = _load_settings() + print(f" Settings loaded: threshold={settings_config['skip_threshold_pct']}%, " + f"auto-persist={'ON' if settings_config['auto_persist'] else 'OFF'}, " + f"cooldown={settings_config['skip_cooldown_seconds']}s") + if settings_config["auto_persist"]: + print(" Auto-persist is ON — queue will survive server exit.") + print(f"\n Jam Session running at http://{ip}:{server_port}") print(f" Host URL: http://{ip}:{server_port}/?host={host_token}\n") @@ -557,7 +642,8 @@ async def ws_endpoint(websocket: WebSocket): host_conn_id=session.get_host_conn_id(), vote_counts=session.get_vote_counts(), my_votes=session.get_votes_for(conn_id), - skip_state=session.get_skip_state(conn_id, 50), + skip_state=session.get_skip_state(conn_id, settings_config["skip_threshold_pct"]), + settings=settings_config, ) while True: data = await websocket.receive_json() diff --git a/jam_session/ws.py b/jam_session/ws.py index 3b83572..d3f8d3c 100644 --- a/jam_session/ws.py +++ b/jam_session/ws.py @@ -49,6 +49,7 @@ class ConnectionManager: vote_counts: dict[str, int] | None = None, my_votes: list[str] | None = None, skip_state: dict | None = None, + settings: dict | None = None, ): payload: dict = { "queue_state": queue_state.model_dump(), @@ -71,6 +72,13 @@ class ConnectionManager: payload["skip_threshold"] = skip_state["skip_threshold"] payload["skip_cooldown_remaining"] = skip_state["skip_cooldown_remaining"] + if settings is not None: + payload["settings"] = { + "skip_threshold_pct": settings["skip_threshold_pct"], + "auto_persist": settings["auto_persist"], + "skip_cooldown_seconds": settings["skip_cooldown_seconds"], + } + await websocket.send_json({"type": "server.sync", "payload": payload}) async def broadcast_queue_delta( @@ -152,6 +160,18 @@ class ConnectionManager: } ) + async def broadcast_settings_update(self, config: dict): + await self.broadcast( + { + "type": "server.settings_update", + "payload": { + "skip_threshold_pct": config["skip_threshold_pct"], + "auto_persist": config["auto_persist"], + "skip_cooldown_seconds": config["skip_cooldown_seconds"], + }, + } + ) + async def broadcast_queue_sort(self, ordered_ids: list[str]): await self.broadcast( {