import argparse import asyncio import io import json import os import re import secrets import signal import socket import sys from datetime import datetime, timezone from pathlib import Path from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi.responses import JSONResponse, Response from fastapi.staticfiles import StaticFiles from jam_session.cache import AudioCacheManager from jam_session.models import Track, QueueState from jam_session.music import MusicController from jam_session.session import NicknameTakenError, SessionManager, generate_nickname from jam_session.ws import ConnectionManager _event_loop: asyncio.AbstractEventLoop | None = None server_host: str = "0.0.0.0" server_port: int = 8090 _lan_ip: str | None = None def detect_lan_ip() -> str | None: try: addrs = socket.getaddrinfo(socket.gethostname(), None) except Exception: return None for addr in addrs: ip = addr[4][0] if ip.startswith("192.168.") or ip.startswith("10."): return ip if ip.startswith("172."): parts = ip.split(".") if 16 <= int(parts[1]) <= 31: return ip for addr in addrs: ip = addr[4][0] if ip != "127.0.0.1" and addr[0] == socket.AF_INET: return ip try: return socket.gethostbyname(socket.gethostname()) except Exception: return None _mdns_instance = None def start_mdns(host: str, port: int): global _mdns_instance try: from zeroconf import ServiceInfo, Zeroconf _mdns_instance = Zeroconf() info = ServiceInfo( "_jam-session._tcp.local.", "Jam Session._jam-session._tcp.local.", addresses=[socket.inet_aton(host)], port=port, ) _mdns_instance.register_service(info) except Exception: pass def stop_mdns(): global _mdns_instance if _mdns_instance is not None: try: _mdns_instance.unregister_all_services() _mdns_instance.close() except Exception: pass _mdns_instance = None SETTINGS_PATH = Path.home() / ".config" / "jam-session" / "settings.json" SESSIONS_DIR = Path.home() / ".local" / "share" / "jam-session" / "sessions" LAST_SESSION_PATH = SESSIONS_DIR.parent / "last-session.json" settings_config: dict = { "skip_threshold_pct": 50, "auto_persist": False, "skip_cooldown_seconds": 3, } 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) def _save_session(name: str) -> int: if not re.match(r'^[A-Za-z0-9_\-]+$', name): raise ValueError("Session name can only contain letters, numbers, underscores, and hyphens") if len(name) > 100: raise ValueError("Session name must be 100 characters or fewer") SESSIONS_DIR.mkdir(parents=True, exist_ok=True) filepath = SESSIONS_DIR / f"{name}.json" tracks_data = [] for t in session._queue: tracks_data.append({ "url": t.webpage_url, "title": t.title, "source_id": t.source_id, }) data = { "saved_at": datetime.now(timezone.utc).isoformat(), "tracks": tracks_data, } tmp = filepath.with_suffix(".json.tmp") tmp.write_text(json.dumps(data, indent=2)) os.replace(tmp, filepath) return len(tracks_data) def _load_session(name: str) -> int: if not re.match(r'^[A-Za-z0-9_\-]+$', name): raise ValueError("Invalid session name") if len(name) > 100: raise ValueError("Session name too long") filepath = SESSIONS_DIR / f"{name}.json" if not filepath.exists(): raise FileNotFoundError(f"Session '{name}' not found") try: data = json.loads(filepath.read_text()) except json.JSONDecodeError: raise ValueError("Session file is corrupted — could not parse JSON") session._votes.clear() session._skip_votes.clear() session._skip_triggered = False tracks_list = data.get("tracks", []) loaded_count = 0 for tinfo in tracks_list: url = tinfo.get("url") if not url: continue try: info = music.resolve_youtube(url) except ValueError: continue track = Track( title=info["title"], duration=info["duration"], stream_url=info["stream_url"], thumbnail=info["thumbnail"], source_id=info["source_id"], webpage_url=info["webpage_url"], ) session._queue.append(track) loaded_count += 1 return loaded_count def _auto_backup() -> int: LAST_SESSION_PATH.parent.mkdir(parents=True, exist_ok=True) tracks_data = [] for t in session._queue: tracks_data.append({ "url": t.webpage_url, "title": t.title, "source_id": t.source_id, }) data = { "saved_at": datetime.now(timezone.utc).isoformat(), "tracks": tracks_data, } tmp = LAST_SESSION_PATH.with_suffix(".json.tmp") tmp.write_text(json.dumps(data, indent=2)) os.replace(tmp, LAST_SESSION_PATH) return len(tracks_data) async def _prompt_save_terminal(): n = len(session._queue) if n == 0: return loop = asyncio.get_running_loop() prompt = f"\nQueue has {n} tracks. Save session before exit? (y/N/name): " try: response = await loop.run_in_executor(None, input, prompt) except (EOFError, RuntimeError): return response = response.strip() if not response or response.lower() == "n": return if response.lower() == "y": _save_session("last-session") print(f" Session saved as last-session ({n} tracks)") else: try: _save_session(response) print(f" Session saved as {response} ({n} tracks)") except ValueError as e: print(f" Could not save: {e}") return await _prompt_save_terminal() async def _prompt_load_last(): if not LAST_SESSION_PATH.exists(): return try: data = json.loads(LAST_SESSION_PATH.read_text()) track_count = len(data.get("tracks", [])) except (json.JSONDecodeError, OSError): return if track_count == 0: return loop = asyncio.get_running_loop() prompt = f"\nLoad last session? ({track_count} tracks) (y/N): " try: response = await loop.run_in_executor(None, input, prompt) except (EOFError, RuntimeError): return if response.strip().lower() == "y": tracks_list = data.get("tracks", []) loaded_count = 0 for tinfo in tracks_list: url = tinfo.get("url") if not url: continue try: info = music.resolve_youtube(url) except ValueError: continue track = Track( title=info["title"], duration=info["duration"], stream_url=info["stream_url"], thumbnail=info["thumbnail"], source_id=info["source_id"], webpage_url=info["webpage_url"], ) session._queue.append(track) loaded_count += 1 if loaded_count == 0: return print(f" Loaded {loaded_count} tracks from last-session.") if session._current_track is None and session._queue: track = session._queue.pop(0) session._current_track = track music.start_mpv() music.play_track(track) cache = AudioCacheManager(max_size_gb=1.0) session = SessionManager() manager = ConnectionManager() def on_mpv_event(event_type: str, data=None): loop = _event_loop if loop is None: return if event_type == "track_finished": asyncio.run_coroutine_threadsafe(_handle_track_finished(), loop) elif event_type == "pause_changed": session.paused = bool(data) track_dict = session._current_track.model_dump(mode="json") if session._current_track else None asyncio.run_coroutine_threadsafe( manager.broadcast_playback_update(track_dict, session.paused), loop ) async def _handle_track_finished(): next_track = await session.advance() if session.is_manual_order(): session.set_manual_order(False) async with session._lock: session._sort_by_votes() session.reset_skip_votes() session.start_skip_cooldown(settings_config["skip_cooldown_seconds"]) if next_track is not None: music.play_track(next_track) else: music.stop_mpv() track_dict = next_track.model_dump(mode="json") if next_track else None await manager.broadcast_playback_update(track_dict, session.paused) new_order = [t.id for t in session._queue] vote_counts = session.get_vote_counts() await manager.broadcast_votes_update(vote_counts) await manager.broadcast_queue_sort(new_order) music = MusicController(session, cache=cache, event_callback=on_mpv_event) async def handle_message(msg: dict, conn_id: str): msg_type = msg.get("type", "") if msg_type == "client.host_claim": payload = msg.get("payload", {}) token = payload.get("token") if not token: await manager.send_error( manager.active_connections.get(conn_id), "Missing host token" ) return if not session.validate_host_token(token): await manager.send_error( manager.active_connections.get(conn_id), "Invalid host token" ) return session.set_host_conn_id(conn_id) await manager.send_to(conn_id, { "type": "server.role", "payload": {"role": "host"} }) await manager.broadcast_nicknames_update(session.get_all_nicknames()) elif msg_type == "client.host.pause": if conn_id != session.get_host_conn_id(): await manager.send_error( manager.active_connections.get(conn_id), "Only the host can pause playback" ) return music.pause() elif msg_type == "client.host.resume": if conn_id != session.get_host_conn_id(): await manager.send_error( manager.active_connections.get(conn_id), "Only the host can resume playback" ) return music.resume() elif msg_type == "client.host.skip": if conn_id != session.get_host_conn_id(): await manager.send_error( manager.active_connections.get(conn_id), "Only the host can skip tracks" ) return music.skip() elif msg_type == "client.skip_vote": payload = msg.get("payload", {}) action = payload.get("action") if action not in ("cast", "remove"): await manager.send_error( manager.active_connections.get(conn_id), "Invalid action for skip_vote" ) return nickname = session.get_nickname(conn_id) if not nickname: await manager.send_error( manager.active_connections.get(conn_id), "Set a nickname before voting to skip" ) return threshold_pct = settings_config["skip_threshold_pct"] now = asyncio.get_running_loop().time() if now < session._skip_cooldown_until: await manager.send_error( manager.active_connections.get(conn_id), "Skip voting is in cooldown" ) return state = await session.toggle_skip_vote(conn_id, threshold_pct) if state["triggered"]: music.skip() 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.host.save_session": if conn_id != session.get_host_conn_id(): await manager.send_error( manager.active_connections.get(conn_id), "Only the host can save sessions" ) return payload = msg.get("payload", {}) name = payload.get("name", "").strip() if not name: await manager.send_error( manager.active_connections.get(conn_id), "Session name is required" ) return try: count = _save_session(name) except ValueError as e: await manager.send_error( manager.active_connections.get(conn_id), str(e) ) return await manager.send_to(conn_id, { "type": "server.session_saved", "payload": {"name": name, "track_count": count} }) elif msg_type == "client.host.load_session": if conn_id != session.get_host_conn_id(): await manager.send_error( manager.active_connections.get(conn_id), "Only the host can load sessions" ) return payload = msg.get("payload", {}) name = payload.get("name", "").strip() if not name: await manager.send_error( manager.active_connections.get(conn_id), "Session name is required" ) return try: count = _load_session(name) except (ValueError, FileNotFoundError) as e: await manager.send_error( manager.active_connections.get(conn_id), str(e) ) return for track in session._queue[-count:] if count > 0 else []: await manager.broadcast_queue_delta("added", track.model_dump(mode="json"), len(session._queue) - 1) await manager.send_to(conn_id, { "type": "server.session_loaded", "payload": {"name": name, "track_count": count} }) elif msg_type == "client.host.list_sessions": if conn_id != session.get_host_conn_id(): await manager.send_error( manager.active_connections.get(conn_id), "Only the host can list sessions" ) return sessions = [] if SESSIONS_DIR.exists(): for f in sorted(SESSIONS_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True): try: data = json.loads(f.read_text()) sessions.append({ "name": f.stem, "saved_at": data.get("saved_at", ""), "track_count": len(data.get("tracks", [])), }) except (json.JSONDecodeError, OSError): continue await manager.send_to(conn_id, { "type": "server.sessions_list", "payload": {"sessions": sessions} }) elif msg_type == "client.host.save_and_stop": if conn_id != session.get_host_conn_id(): await manager.send_error( manager.active_connections.get(conn_id), "Only the host can stop the server" ) return payload = msg.get("payload", {}) name = payload.get("name", "").strip() if name: try: _save_session(name) except ValueError as e: await manager.send_error( manager.active_connections.get(conn_id), str(e) ) return _auto_backup() session._nicknames.clear() session._nickname_index.clear() session._votes.clear() session._skip_votes.clear() await manager.broadcast({ "type": "server.shutdown", "payload": {"reason": "Host stopped the server"} }) import os as _os _os._exit(0) elif msg_type == "client.host.stop_server": if conn_id != session.get_host_conn_id(): await manager.send_error( manager.active_connections.get(conn_id), "Only the host can stop the server" ) return _auto_backup() session._nicknames.clear() session._nickname_index.clear() session._votes.clear() session._skip_votes.clear() await manager.broadcast({ "type": "server.shutdown", "payload": {"reason": "Host stopped the server"} }) import os as _os _os._exit(0) elif msg_type == "client.vote": payload = msg.get("payload", {}) track_id = payload.get("track_id") if not track_id: await manager.send_error( manager.active_connections.get(conn_id), "Missing 'track_id' in payload" ) return try: new_count, was_upvoted = await session.toggle_vote(conn_id, track_id) except ValueError as e: await manager.send_error( manager.active_connections.get(conn_id), str(e) ) return vote_counts = session.get_vote_counts() await manager.broadcast_votes_update(vote_counts) if not session.is_manual_order(): ordered_ids = [t.id for t in session._queue] await manager.broadcast_queue_sort(ordered_ids) elif msg_type == "client.unvote": payload = msg.get("payload", {}) track_id = payload.get("track_id") if not track_id: await manager.send_error( manager.active_connections.get(conn_id), "Missing 'track_id' in payload" ) return try: new_count, was_upvoted = await session.toggle_vote(conn_id, track_id) except ValueError as e: await manager.send_error( manager.active_connections.get(conn_id), str(e) ) return vote_counts = session.get_vote_counts() await manager.broadcast_votes_update(vote_counts) if not session.is_manual_order(): ordered_ids = [t.id for t in session._queue] await manager.broadcast_queue_sort(ordered_ids) elif msg_type == "client.nickname_set": payload = msg.get("payload", {}) new_nickname = payload.get("nickname", "").strip() if not new_nickname or len(new_nickname) > 20: await manager.send_nickname_error( manager.active_connections.get(conn_id), "Nickname must be 1–20 characters using letters, numbers, spaces, or hyphens." ) return if not re.match(r'^[A-Za-z0-9\s\-]+$', new_nickname): await manager.send_nickname_error( manager.active_connections.get(conn_id), "Nickname must be 1–20 characters using letters, numbers, spaces, or hyphens." ) return normalized = "-".join(word.capitalize() for word in new_nickname.split()) try: await session.set_nickname(conn_id, normalized) except NicknameTakenError: await manager.send_nickname_error( manager.active_connections.get(conn_id), "That nickname is already taken. Try a different one." ) return await manager.broadcast_nicknames_update(session.get_all_nicknames()) await manager.send_to(conn_id, { "type": "server.nickname_assigned", "payload": {"nickname": normalized} }) elif msg_type == "client.add": payload = msg.get("payload", {}) url = payload.get("url") if not url: await manager.send_error( manager.active_connections.get(conn_id), "Missing 'url' in payload" ) return try: info = music.resolve_youtube(url) except ValueError as e: await manager.send_error( manager.active_connections.get(conn_id), str(e) ) return track = Track( title=info["title"], duration=info["duration"], stream_url=info["stream_url"], thumbnail=info["thumbnail"], source_id=info["source_id"], webpage_url=info["webpage_url"], ) await session.add_track(track) if settings_config.get("auto_persist"): _auto_backup() queue_empty_before = len(session._queue) == 1 and session._current_track is None if queue_empty_before: music.start_mpv() music.play_track(track) await manager.broadcast_queue_delta("added", track.model_dump(mode="json"), len(session._queue) - 1) await cache.pre_fetch_upcoming(list(session._queue[:cache.prefetch_count])) elif msg_type == "client.remove": payload = msg.get("payload", {}) track_id = payload.get("track_id") if not track_id: await manager.send_error( manager.active_connections.get(conn_id), "Missing 'track_id' in payload" ) return await session.remove_track(track_id) await manager.broadcast_queue_delta("removed") if settings_config.get("auto_persist"): _auto_backup() elif msg_type == "client.reorder": payload = msg.get("payload", {}) order = payload.get("order") if not order or not isinstance(order, list): await manager.send_error( manager.active_connections.get(conn_id), "Missing or invalid 'order' in payload", ) return session.set_manual_order(True) try: await session.reorder(order) except ValueError as e: await manager.send_error( manager.active_connections.get(conn_id), str(e) ) return await manager.broadcast_queue_delta("reordered") if settings_config.get("auto_persist"): _auto_backup() async def broadcast_playback_loop(): while True: await asyncio.sleep(1) if music.mpv_process is not None: if music.mpv_process.poll() is None: t = music.get_playback_time() await manager.broadcast_time_update(t) else: try: music.start_mpv() if session._current_track is not None: music.play_track(session._current_track) except Exception: pass async def lifespan(app: FastAPI): global _event_loop, server_host, server_port, _lan_ip _event_loop = asyncio.get_running_loop() _lan_ip = detect_lan_ip() ip = server_host if server_host != "0.0.0.0" else (_lan_ip or "127.0.0.1") server_host = ip 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.") await _prompt_load_last() print(f" Sessions directory: {SESSIONS_DIR}") loop = asyncio.get_running_loop() print(f"\n Jam Session running at http://{ip}:{server_port}") print(f" Host URL: http://{ip}:{server_port}/?host={host_token}\n") display_ip = _lan_ip or ip print(f" Share this URL with guests: http://{display_ip}:{server_port}") try: import qrcode qr = qrcode.QRCode() qr.add_data(f"http://{display_ip}:{server_port}") qr.make(fit=True) qr.print_ascii() except Exception: pass start_mdns(display_ip, server_port) loop_task = asyncio.create_task(broadcast_playback_loop()) async def cleanup_held_nicknames(): while True: await asyncio.sleep(5) await session.release_expired_holds() cleanup_task = asyncio.create_task(cleanup_held_nicknames()) yield print("\n Saving auto-backup...") n = _auto_backup() if n > 0: print(f" Auto-backup: {n} tracks saved to last-session.json") if session._queue and not settings_config.get("auto_persist"): await _prompt_save_terminal() session._nicknames.clear() session._nickname_index.clear() session._votes.clear() session._skip_votes.clear() session._manual_order = False stop_mdns() cleanup_task.cancel() try: await cleanup_task except asyncio.CancelledError: pass loop_task.cancel() try: await loop_task except asyncio.CancelledError: pass music.shutting_down = True music.stop_mpv() app = FastAPI(lifespan=lifespan) static_dir = Path(__file__).resolve().parent.parent / "static" _has_static_dir = static_dir.exists() @app.post("/api/queue", status_code=201) async def add_to_queue(request: Request): try: body = await request.json() except Exception: return JSONResponse( status_code=422, content={"error": "Invalid JSON body"} ) url = body.get("url") if not url: return JSONResponse( status_code=422, content={"error": "Missing 'url' field"} ) try: info = music.resolve_youtube(url) except ValueError as e: return JSONResponse(status_code=422, content={"error": str(e)}) track = Track( title=info["title"], duration=info["duration"], stream_url=info["stream_url"], thumbnail=info["thumbnail"], source_id=info["source_id"], webpage_url=info["webpage_url"], ) await session.add_track(track) if session._queue == [track]: music.start_mpv() music.play_track(track) return track.model_dump() @app.get("/api/queue") async def get_queue(): return session.get_state().model_dump() @app.delete("/api/queue/{track_id}", status_code=204) async def remove_from_queue(track_id: str): removed = await session.remove_track(track_id) if removed is None: return JSONResponse(status_code=404, content={"error": "Track not found"}) @app.put("/api/queue/reorder") async def reorder_queue(request: Request): try: body = await request.json() except Exception: return JSONResponse( status_code=422, content={"error": "Invalid JSON body"} ) order = body.get("order") if not order or not isinstance(order, list): return JSONResponse( status_code=422, content={"error": "Missing or invalid 'order' field"} ) try: await session.reorder(order) except ValueError as e: return JSONResponse(status_code=422, content={"error": str(e)}) return session.get_state().model_dump() @app.get("/api/qrcode") async def qr_code(): import qrcode qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M) display_ip = _lan_ip or server_host qr.add_data(f"http://{display_ip}:{server_port}") qr.make(fit=True) img = qr.make_image(fill_color="black", back_color="white") buf = io.BytesIO() img.save(buf, format="PNG") buf.seek(0) return Response(content=buf.getvalue(), media_type="image/png") @app.websocket("/ws") async def ws_endpoint(websocket: WebSocket): conn_id = await manager.connect(websocket) nickname = generate_nickname() try: await session.set_nickname(conn_id, nickname) except NicknameTakenError: for _ in range(5): nickname = generate_nickname() try: await session.set_nickname(conn_id, nickname) break except NicknameTakenError: continue try: await manager.send_sync( websocket, session.get_state(), music.get_playback_time() if music.mpv_process else 0, conn_id=conn_id, 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, settings_config["skip_threshold_pct"]), settings=settings_config, server_url=f"http://{_lan_ip or server_host}:{server_port}", ) while True: data = await websocket.receive_json() await handle_message(data, conn_id) except WebSocketDisconnect: if session.is_host(conn_id): session.set_host_conn_id(None) await session.hold_nickname(conn_id) manager.disconnect(conn_id) await manager.broadcast_nicknames_update(session.get_all_nicknames()) if _has_static_dir: app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") def main(): global server_host, server_port parser = argparse.ArgumentParser(description="Jam Session server") parser.add_argument( "--bind", default="auto", help="IP to bind to (auto, specific IP, or 0.0.0.0)" ) parser.add_argument("--port", default=8090, type=int, help="Port to listen on") args = parser.parse_args() server_port = args.port if args.bind == "auto": server_host = detect_lan_ip() or "0.0.0.0" else: server_host = args.bind import uvicorn uvicorn.run(app, host=server_host, port=server_port)