Files
lintunes/lintunes/cast/server.py
T
travandClaude Opus 5 4b510318c6 v0.10.1: ending a cast no longer freezes the window
serve_forever's poll_interval is how long shutdown() blocks waiting for the
loop to notice it should stop, and the default is 0.5 s. That whole half
second was paid on the GUI thread every time a cast session ended: the
click on the cast indicator goes disconnect() → _teardown → sink.shutdown()
→ server.stop() → httpd.shutdown(), all synchronous. Measured here: 450 ms
of frozen window, now 10 ms.

The cost is 100 select() wakeups a second instead of 2, on a thread that
only exists for the life of a cast session.

Found uncommitted in the working tree, left over from the cast round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4Z46BMQYS57bcbxWbSS3C
2026-08-22 13:15:42 -04:00

214 lines
7.6 KiB
Python

"""A minimal local HTTP server that hands single files to the Chromecast.
The device fetches audio over the LAN, so it needs a URL rather than a path.
This is the only server code in lintunes, so the exposure is kept as small as
it can be:
* It binds an ephemeral port and lives only as long as the cast session.
* The URL never carries a path. ``publish()`` mints an opaque random token and
keeps the ``token -> absolute path`` mapping in memory; an unknown token is a
404. Path traversal isn't defended against, it's structurally impossible —
there is no path in the request to traverse with.
* Only tracks actually being cast are published, and only the few most recent
stay resolvable.
Range requests are mandatory, not an optimization: the Chromecast seeks by
re-requesting byte ranges, and won't report a duration without ``Accept-Ranges``.
"""
import io
import secrets
import threading
from dataclasses import dataclass
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from lintunes.cast.support import parse_range
# How many previously published tracks stay resolvable. More than one because
# the device re-requests the current file when it seeks and can briefly reach
# back for the previous one across a track change; small because every live
# token is a file this machine will hand to anything on the LAN that asks.
# Audio and artwork are counted separately so a track's cover can't evict the
# audio of the track before it.
KEEP_TOKENS = 3
CHUNK = 64 * 1024
@dataclass(frozen=True)
class _Asset:
"""Something published for the device to fetch: a library file on disk, or
a blob held in memory (album art, which is extracted from tags rather than
being a file of its own)."""
content_type: str
path: Path | None = None
data: bytes | None = None
def size(self) -> int:
if self.data is not None:
return len(self.data)
return self.path.stat().st_size
def open(self):
if self.data is not None:
return io.BytesIO(self.data)
return open(self.path, "rb")
class _Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1" # Chromecast wants keep-alive + Content-Length
# BaseHTTPRequestHandler logs every request to stderr; the app has a status
# bar for anything worth saying.
def log_message(self, format, *args):
pass
@property
def _tracks(self):
return self.server.track_server
def _resolve(self) -> "_Asset | None":
prefix = "/t/"
if not self.path.startswith(prefix):
return None
return self._tracks.lookup(self.path[len(prefix):])
def do_HEAD(self):
self._serve(body=False)
def do_GET(self):
self._serve(body=True)
def _serve(self, body: bool):
asset = self._resolve()
if asset is None:
self.send_error(HTTPStatus.NOT_FOUND)
return
try:
size = asset.size()
handle = asset.open()
except OSError:
# The file moved or the drive went away since it was published.
self.send_error(HTTPStatus.NOT_FOUND)
return
with handle:
span = parse_range(self.headers.get("Range"), size)
if span is None:
start, end = 0, max(size - 1, 0)
status = HTTPStatus.OK
else:
start, end = span
status = HTTPStatus.PARTIAL_CONTENT
length = end - start + 1 if size else 0
self.send_response(status)
self.send_header("Content-Type", asset.content_type)
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Length", str(length))
if status == HTTPStatus.PARTIAL_CONTENT:
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.end_headers()
if not body or not length:
return
handle.seek(start)
remaining = length
try:
while remaining > 0:
chunk = handle.read(min(CHUNK, remaining))
if not chunk:
break
self.wfile.write(chunk)
remaining -= len(chunk)
except (BrokenPipeError, ConnectionResetError):
# Normal: the device drops the connection the moment it seeks
# or the track is replaced. Nothing to report.
pass
class TrackServer:
"""Serves published files over the LAN for the duration of a cast session.
Thread-safe: ``publish`` is called from the GUI thread while the server's
own threads read the token map.
"""
def __init__(self):
self._httpd: ThreadingHTTPServer | None = None
self._thread: threading.Thread | None = None
self._lock = threading.Lock()
self._assets: dict[str, _Asset] = {}
# One eviction ring per kind, so publishing a track's cover can't push
# the previous track's audio out from under a device still fetching it.
self._rings: dict[str, list[str]] = {"audio": [], "art": []}
# ---- lifecycle ----
def start(self) -> int:
"""Bind an ephemeral port on every interface and serve. Returns the port."""
if self._httpd is not None:
return self.port
# 0.0.0.0, not localhost: the Chromecast fetches from another machine.
httpd = ThreadingHTTPServer(("0.0.0.0", 0), _Handler)
httpd.daemon_threads = True
httpd.track_server = self
self._httpd = httpd
# poll_interval is how long shutdown() may block waiting for the
# serve_forever loop to notice: the default 0.5 s is paid on the GUI
# thread every time a cast session ends.
self._thread = threading.Thread(
target=lambda: httpd.serve_forever(poll_interval=0.01), daemon=True)
self._thread.start()
return self.port
@property
def port(self) -> int:
return self._httpd.server_address[1] if self._httpd else 0
def stop(self):
"""Shut the listener down. Idempotent."""
httpd, self._httpd = self._httpd, None
if httpd is not None:
httpd.shutdown()
httpd.server_close()
self._thread = None
with self._lock:
self._assets.clear()
for ring in self._rings.values():
ring.clear()
# ---- publishing ----
def publish(self, path, content_type: str) -> str:
"""Make a library file fetchable and return its token."""
return self._publish(
_Asset(content_type=content_type, path=Path(path)), "audio")
def publish_bytes(self, data: bytes, content_type: str) -> str:
"""Make an in-memory blob (album art) fetchable and return its token."""
return self._publish(
_Asset(content_type=content_type, data=data), "art")
def _publish(self, asset: _Asset, kind: str) -> str:
with self._lock:
token = secrets.token_urlsafe(16)
self._assets[token] = asset
ring = self._rings[kind]
ring.append(token)
# Drop the oldest so a long listening session doesn't leave the
# whole played history reachable.
while len(ring) > KEEP_TOKENS:
self._assets.pop(ring.pop(0), None)
return token
def lookup(self, token: str) -> _Asset | None:
with self._lock:
return self._assets.get(token)
def url_for(self, host: str, token: str) -> str:
return f"http://{host}:{self.port}/t/{token}"