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
214 lines
7.6 KiB
Python
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}"
|