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>
161 lines
5.2 KiB
Python
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)
|