Something went wrong. Try again.
A local, collaborative music player (main-branch-only mirror of forge.ejuarezg.com/ejuarezg/jam-session)
Something went wrong. Try again.
32 kB · 1039 lines
Python
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040import argparseimport asyncioimport ioimport jsonimport osimport reimport secretsimport signalimport socketimport sysfrom datetime import datetime, timezonefrom pathlib import Path
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnectfrom fastapi.responses import JSONResponse, Responsefrom fastapi.staticfiles import StaticFiles
from jam_session.cache import AudioCacheManagerfrom jam_session.models import Track, QueueStatefrom jam_session.music import MusicControllerfrom jam_session.session import NicknameTakenError, SessionManager, generate_nicknamefrom jam_session.ws import ConnectionManager
_event_loop: asyncio.AbstractEventLoop | None = Noneserver_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)