Files
lintunes/lintunes/cast/discovery.py
T
travandClaude Opus 5 087c4bf103 v0.4.0: cast to a Chromecast from the Connections menu
The Device menu becomes Connections, with "Connect to Chromecast…" beside
the Rabbit sync. It opens a dialog that spins while it searches and lists
devices as they appear; picking one hands playback over, puts a small cast
glyph under the volume slider, and clicking that glyph disconnects.

Media-receiver model: lintunes serves the original file over the LAN on an
ephemeral port and the Chromecast decodes it itself. Bit-exact — no
transcode, no second lossy encode — and the device buffers for itself. The
cost is that lintunes is a remote control while connected: no local PCM, so
the visualizer shows the cast glyph instead of bars, and transport actions
land with about a second of round trip.

Player now walks its queue through a swappable PlaybackSink. It keeps
owning the queue, shuffle walk, start/stop times and the play-count and
scrobble bookkeeping, so casting counts plays and scrobbles exactly like
local playback. set_sink() carries track, position and playing-state both
ways, so connecting and disconnecting pick up mid-song. LocalSink stays in
player.py because the Player tests stub Qt Multimedia in that namespace.

The URL carries an opaque random token, never a path, so there is nothing
to traverse with; only the last few played tracks stay resolvable and the
whole map dies with the session. Range and HEAD are implemented because
the device seeks by re-requesting ranges and won't report a duration
without them.

Failure handling, verified against a real device: a dropped socket gets a
15s grace period, since pychromecast retries on its own and a Wi-Fi blip
heals itself. A real loss, another app taking the device, or a network
change falls back to local playback still playing, at the same position —
the sink reports the state lintunes last asked for rather than the IDLE
status a dying connection pushes just before it goes. Quitting stops the
device instead of leaving it fetching from a server that just died. The
~119 Apple Lossless / AIFF / protected-AAC tracks are skipped with a
status-bar message; the other 21,000+ MP3 and AAC files cast natively.

New dependency: pychromecast>=14.0.10, imported lazily so the app still
launches where it isn't installed (the menu item then explains the
install), and python_requires raised to >=3.11 to match its floor.

NOTE: the other machine needs `pip install -e .` before casting appears.

tests/test_round29.py: 67 tests; 440 pass overall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:49:53 -04:00

161 lines
5.2 KiB
Python

"""Finding Chromecasts on the local network.
pychromecast's CastBrowser does the mDNS work on its own thread; everything
leaves here as a Qt signal, so the picker dialog never touches zeroconf state
directly and nothing touches a widget off the GUI thread.
Discovery runs only while the picker is open — an idle mDNS browser is chatter
on the network for no benefit.
"""
from dataclasses import dataclass
from PyQt6.QtCore import QObject, pyqtSignal
@dataclass(frozen=True)
class CastDevice:
uuid: str
name: str
model: str
host: str
port: int
def is_available() -> tuple[bool, str]:
"""(True, "") when pychromecast can be imported, else (False, why not).
Uses find_spec rather than a real import: the menu asks this every time it
opens, and there's no reason to drag protobuf and zeroconf into the process
just to decide whether to grey an item out.
"""
import importlib.util
try:
if importlib.util.find_spec("pychromecast") is None:
return False, "pychromecast isn't installed"
except (ImportError, ValueError):
return False, "pychromecast isn't installed"
return True, ""
def _to_device(info) -> CastDevice:
"""Every pychromecast field access in one place.
CastInfo's shape has drifted across major versions (host/hostname, services
vs a bare host), so a version bump should be a fix here rather than a hunt
through the dialog.
"""
host, port = "", 8009
services = getattr(info, "services", None)
if services:
first = next(iter(services))
host = getattr(first, "host", "") or ""
port = getattr(first, "port", 8009) or 8009
host = host or getattr(info, "host", "") or ""
return CastDevice(
uuid=str(info.uuid),
name=info.friendly_name or "Chromecast",
model=getattr(info, "model_name", "") or "",
host=host,
port=port,
)
class CastDiscovery(QObject):
"""Browses for Chromecasts, reporting them as they appear and disappear."""
device_found = pyqtSignal(object) # CastDevice
device_lost = pyqtSignal(object) # CastDevice
failed = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._browser = None
self._zconf = None
self._devices: dict[str, CastDevice] = {}
def is_running(self) -> bool:
return self._browser is not None
def devices(self) -> list[CastDevice]:
return list(self._devices.values())
def browser(self):
"""The live CastBrowser, needed to build a Chromecast from a pick."""
return self._browser
def zconf(self):
return self._zconf
def start(self):
if self._browser is not None:
return
# Imported here, not at module scope: lintunes must still launch on a
# machine that has pulled this code but not yet run `pip install -e .`.
try:
import pychromecast
import zeroconf
except ImportError as e:
self.failed.emit(f"Chromecast support isn't installed ({e}).")
return
outer = self
class _Listener(pychromecast.discovery.AbstractCastListener):
"""Callbacks arrive on the browser's thread; they only ever emit."""
def add_cast(self, uuid, service):
outer._changed(uuid, added=True)
def remove_cast(self, uuid, service, cast_info):
outer._removed(uuid)
def update_cast(self, uuid, service):
outer._changed(uuid, added=False)
try:
self._zconf = zeroconf.Zeroconf()
self._browser = pychromecast.CastBrowser(_Listener(), self._zconf)
self._browser.start_discovery()
except Exception as e: # noqa: BLE001 — report, never crash the dialog
self.stop()
self.failed.emit(f"Couldn't search for Chromecasts: {e}")
def stop(self):
"""Idempotent: safe to call from every dialog exit path."""
browser, self._browser = self._browser, None
zconf, self._zconf = self._zconf, None
if browser is not None:
try:
browser.stop_discovery()
except Exception: # noqa: BLE001 — teardown is best-effort
pass
if zconf is not None:
try:
zconf.close()
except Exception: # noqa: BLE001
pass
self._devices.clear()
# ---- browser thread ----
def _changed(self, uuid, added: bool):
browser = self._browser
if browser is None:
return
info = browser.devices.get(uuid)
if info is None:
return
device = _to_device(info)
known = self._devices.get(device.uuid)
self._devices[device.uuid] = device
# An update for a device already listed would otherwise add a duplicate
# row every time zeroconf refreshes its record.
if known is None or added:
self.device_found.emit(device)
def _removed(self, uuid):
device = self._devices.pop(str(uuid), None)
if device is not None:
self.device_lost.emit(device)