Skip to content

Commit e52e5aa

Browse files
authored
Add serve command (#12)
* Add serve command * Handle ctrl+c better
1 parent 4236d92 commit e52e5aa

10 files changed

Lines changed: 925 additions & 2 deletions

File tree

‎README.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,19 @@ Connect to any [Sendspin](https://www.sendspin-audio.com) server and instantly t
1111
## Quick Start
1212

1313
**Run directly with [uv](https://docs.astral.sh/uv/getting-started/installation/):**
14+
15+
Start client
16+
1417
```bash
1518
uvx sendspin
1619
```
1720

21+
Host a Sendspin party
22+
23+
```bash
24+
uvx sendspin serve /path/to/media.mp3
25+
```
26+
1827
## Installation
1928

2029
**With pip:**
@@ -131,3 +140,16 @@ This player is highly experimental and has several known limitations:
131140

132141
- **Format Support**: Currently fixed to uncompressed 44.1kHz 16-bit stereo PCM
133142
- **Configuration Persistence**: Settings are not persistently stored; delay must be reconfigured on each restart using the `--static-delay-ms` option
143+
144+
## Sendspin Party
145+
146+
The Sendspin client includes a mode to enable hosting a Sendspin Party. This will start a Sendspin server playing a specified audio file or URL in a loop, allowing nearby Sendspin clients to connect and listen together. It also hosts a web interface for easy playing and sharing. Fire up that home or office 🔥
147+
148+
```bash
149+
# Local file
150+
sendspin serve /path/to/media.mp3
151+
# Remote URL
152+
sendspin serve https://retro.dancewave.online/retrodance.mp3
153+
# Without pre-installing Sendspin
154+
uvx sendspin serve /path/to/media.mp3
155+
```

‎pyproject.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ classifiers = [
1515
]
1616
dependencies = [
1717
"aiosendspin",
18+
"av>=14.0.0",
1819
"numpy>=1.24.0",
20+
"qrcode>=8.0",
1921
"readchar>=4.0.0",
2022
"rich>=13.0.0",
2123
"sounddevice>=0.4.6",
@@ -43,6 +45,9 @@ test = [
4345
"ruff==0.14.5",
4446
]
4547

48+
[tool.setuptools.package-data]
49+
"sendspin.serve" = ["web/*"]
50+
4651
[tool.codespell]
4752
# ignore-words-list = ""
4853

@@ -130,6 +135,7 @@ ignore = [
130135

131136
[tool.ruff.lint.per-file-ignores]
132137
"sendspin/cli.py" = ["T201"]
138+
"sendspin/serve/__init__.py" = ["T201"]
133139

134140
select = ["ALL"]
135141

‎sendspin/cli.py‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,33 @@
1515

1616
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
1717
"""Parse CLI arguments for the Sendspin client."""
18-
parser = argparse.ArgumentParser(description="Run a Sendspin CLI client")
18+
parser = argparse.ArgumentParser(description="Sendspin CLI")
19+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
20+
21+
# Serve subcommand
22+
serve_parser = subparsers.add_parser("serve", help="Start a Sendspin server")
23+
serve_parser.add_argument(
24+
"source",
25+
help="Audio source: local file path or URL (http/https)",
26+
)
27+
serve_parser.add_argument(
28+
"--port",
29+
type=int,
30+
default=8927,
31+
help="Port to listen on (default: 8927)",
32+
)
33+
serve_parser.add_argument(
34+
"--name",
35+
default="Sendspin Server",
36+
help="Server name for mDNS discovery",
37+
)
38+
serve_parser.add_argument(
39+
"--loop",
40+
action="store_true",
41+
help="Loop the audio source continuously",
42+
)
43+
44+
# Default behavior (client mode) - existing arguments
1945
parser.add_argument(
2046
"--url",
2147
default=None,
@@ -117,8 +143,30 @@ async def list_servers() -> None:
117143

118144
def main() -> int:
119145
"""Run the CLI client."""
120-
# Handle --list-audio-devices before starting async runtime
121146
args = parse_args(sys.argv[1:])
147+
148+
# Handle serve subcommand
149+
if args.command == "serve":
150+
from sendspin.serve import ServeConfig, run_server
151+
152+
config = ServeConfig(
153+
source=args.source,
154+
port=args.port,
155+
name=args.name,
156+
loop=args.loop,
157+
)
158+
try:
159+
return asyncio.run(run_server(config))
160+
except KeyboardInterrupt:
161+
return 0
162+
except Exception as e:
163+
print(f"Server error: {e}")
164+
import traceback
165+
166+
traceback.print_exc()
167+
return 1
168+
169+
# Handle --list-audio-devices before starting async runtime
122170
if args.list_audio_devices:
123171
list_audio_devices()
124172
return 0

‎sendspin/serve/__init__.py‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Sendspin server application."""
2+
3+
import asyncio
4+
import logging
5+
import signal
6+
import socket
7+
import uuid
8+
from contextlib import suppress
9+
from dataclasses import dataclass
10+
11+
import qrcode
12+
from aiosendspin.server import (
13+
ClientAddedEvent,
14+
ClientRemovedEvent,
15+
SendspinEvent,
16+
SendspinServer,
17+
SendspinGroup,
18+
)
19+
from aiosendspin.server.stream import MediaStream
20+
21+
from .server import SendspinPlayerServer
22+
from .source import decode_audio
23+
24+
logger = logging.getLogger(__name__)
25+
26+
27+
def print_qr_code(url: str) -> None:
28+
"""Print a QR code to the console."""
29+
qr = qrcode.QRCode(
30+
error_correction=qrcode.ERROR_CORRECT_L,
31+
box_size=1,
32+
border=1,
33+
)
34+
qr.add_data(url)
35+
qr.make(fit=True)
36+
qr.print_ascii(invert=True)
37+
38+
39+
def get_local_ip() -> str:
40+
"""Get the local IP address of this machine on the LAN."""
41+
try:
42+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
43+
s.connect(("8.8.8.8", 80))
44+
return s.getsockname()[0]
45+
except Exception:
46+
return "localhost"
47+
48+
49+
@dataclass
50+
class ServeConfig:
51+
"""Configuration for the serve command."""
52+
53+
source: str
54+
port: int = 8927
55+
name: str = "Sendspin Server"
56+
loop: bool = False
57+
58+
59+
async def run_server(config: ServeConfig) -> int:
60+
"""Run the Sendspin server with the given audio source."""
61+
event_loop = asyncio.get_event_loop()
62+
server_id = f"sendspin-cli-{uuid.uuid4().hex[:8]}"
63+
64+
server = SendspinPlayerServer(
65+
loop=event_loop,
66+
server_id=server_id,
67+
server_name=config.name,
68+
)
69+
70+
client_connected = asyncio.Event()
71+
active_group: SendspinGroup | None = None
72+
shutdown_requested = False
73+
74+
def handle_sigint() -> None:
75+
nonlocal shutdown_requested
76+
shutdown_requested = True
77+
print("\nShutting down...")
78+
# Cancel any active playback to trigger shutdown
79+
for task in asyncio.all_tasks():
80+
if task is not asyncio.current_task():
81+
task.cancel()
82+
83+
event_loop.add_signal_handler(signal.SIGINT, handle_sigint)
84+
85+
async def on_server_event(server: SendspinServer, event: SendspinEvent) -> None:
86+
nonlocal active_group
87+
88+
if isinstance(event, ClientAddedEvent):
89+
client = server.get_client(event.client_id)
90+
assert client is not None
91+
92+
print("Client connected", event.client_id)
93+
94+
if active_group is None:
95+
active_group = client.group
96+
client_connected.set()
97+
return
98+
99+
await active_group.add_client(client)
100+
101+
if isinstance(event, ClientRemovedEvent):
102+
if active_group is None:
103+
return
104+
105+
print("Client disconnected", event.client_id)
106+
107+
if not active_group.clients:
108+
active_group = None
109+
110+
server.add_event_listener(on_server_event)
111+
112+
await server.start_server(port=config.port)
113+
local_ip = get_local_ip()
114+
url = f"http://{local_ip}:{config.port}/"
115+
print(f"\nServer running at {url}")
116+
if local_ip == "localhost":
117+
print("Unable to print QR code because no LAN IP available\n")
118+
print("Open in browser to use the web player")
119+
else:
120+
print()
121+
print_qr_code(url)
122+
print()
123+
print("Scan QR to open in browser to use the web player")
124+
print("Or connect with any Sendspin client")
125+
print("Press Ctrl+C to quit\n")
126+
127+
try:
128+
while not shutdown_requested:
129+
# Wait for a client to connect
130+
if not active_group:
131+
client_connected.clear()
132+
await client_connected.wait()
133+
134+
assert active_group is not None
135+
136+
# Decode and stream audio
137+
try:
138+
audio_source = await decode_audio(config.source)
139+
media_stream = MediaStream(
140+
main_channel_source=audio_source.generator,
141+
main_channel_format=audio_source.format,
142+
)
143+
await active_group.play_media(media_stream)
144+
except asyncio.CancelledError:
145+
if shutdown_requested:
146+
break
147+
# A client disconnected - only reset if no active clients remain
148+
if all(client.closing for client in active_group.clients):
149+
active_group = None
150+
except Exception as e:
151+
print(f"Playback error: {e}")
152+
logger.debug("Playback error", exc_info=True)
153+
154+
finally:
155+
event_loop.remove_signal_handler(signal.SIGINT)
156+
with suppress(Exception):
157+
# Temp workaround until https://github.com/Sendspin/aiosendspin/pull/108
158+
for client in server.clients:
159+
await client.disconnect(retry_connection=False)
160+
await server.close()
161+
162+
return 0

‎sendspin/serve/server.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Custom SendspinServer with embedded web player."""
2+
3+
from importlib.resources import files
4+
from pathlib import Path
5+
6+
from aiohttp import web
7+
from aiosendspin.server import SendspinServer
8+
9+
10+
class SendspinPlayerServer(SendspinServer):
11+
"""SendspinServer that serves an embedded web player at /."""
12+
13+
def _create_web_application(self) -> web.Application:
14+
"""Create web app with embedded player and static file serving."""
15+
app = super()._create_web_application()
16+
17+
# Get path to web assets directory
18+
web_path = Path(str(files("sendspin.serve.web")))
19+
20+
# Serve index.html at root
21+
async def index_handler(request: web.Request) -> web.FileResponse:
22+
return web.FileResponse(web_path / "index.html")
23+
24+
app.router.add_get("/", index_handler)
25+
26+
# Serve other static files (css, js)
27+
app.router.add_static("/", web_path)
28+
29+
return app

0 commit comments

Comments
 (0)