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>
This commit is contained in:
2026-08-13 21:49:53 -04:00
co-authored by Claude Opus 5
parent b6b6dc01f7
commit 087c4bf103
26 changed files with 2885 additions and 209 deletions
+2 -2
View File
@@ -56,7 +56,7 @@ def test_missing_file_emits_track_missing_and_stops(qapp, tmp_path):
assert missing == [track]
# Stopped, not advanced into a track; setSource was never called.
assert player.current_track is None
player._media.setSource.assert_not_called()
player._local._media.setSource.assert_not_called()
def test_all_missing_queue_does_not_recurse(qapp, tmp_path):
@@ -104,4 +104,4 @@ def test_retry_current_plays_after_relocate(qapp, tmp_path):
assert errors == [] and missing == []
assert player.current_track is track
player._media.setSource.assert_called_once()
player._local._media.setSource.assert_called_once()
+1 -1
View File
@@ -42,7 +42,7 @@ class TestPlayerVolume:
player.set_volume(0.5)
assert player.volume() == 0.5
# The linear gain handed to QAudioOutput is set (perceptual->linear).
player._audio.setVolume.assert_called()
player._local._audio.setVolume.assert_called()
def test_clamps_out_of_range(self, qapp):
player = _mock_player()
+14 -14
View File
@@ -223,12 +223,12 @@ class TestPlayerShutdown:
def test_shutdown_detaches_in_order_and_is_idempotent(self, qapp, tmp_path):
player = _mock_player(qapp, tmp_path)
player.shutdown()
player._media.stop.assert_called_once()
player._media.setSource.assert_called_once_with(QUrl())
player._media.setAudioBufferOutput.assert_called_with(None)
player._media.setAudioOutput.assert_called_with(None)
player._local._media.stop.assert_called_once()
player._local._media.setSource.assert_called_once_with(QUrl())
player._local._media.setAudioBufferOutput.assert_called_with(None)
player._local._media.setAudioOutput.assert_called_with(None)
player.shutdown() # second call must be a no-op
player._media.stop.assert_called_once()
player._local._media.stop.assert_called_once()
class TestVolumeReapply:
@@ -239,18 +239,18 @@ class TestVolumeReapply:
expected = QAudio.convertVolume(
0.5, QAudio.VolumeScale.LogarithmicVolumeScale,
QAudio.VolumeScale.LinearVolumeScale)
player._audio.setVolume.assert_called_with(expected)
player._local._audio.setVolume.assert_called_with(expected)
def test_resume_reapplies_volume(self, qapp, tmp_path, monkeypatch):
player = _mock_player(qapp, tmp_path)
player._current_track = Track(track_id=1, name="A")
applied = []
monkeypatch.setattr(player, "_apply_volume",
monkeypatch.setattr(player._local, "_apply_volume",
lambda: applied.append(True))
monkeypatch.setattr(player, "is_playing", lambda: False)
player.toggle_play() # paused with a cued track → resume
assert applied == [True]
player._media.play.assert_called_once()
player._local._media.play.assert_called_once()
def test_long_pause_resume_seeks_in_place(self, qapp, tmp_path,
monkeypatch):
@@ -259,15 +259,15 @@ class TestVolumeReapply:
player._current_track = Track(track_id=1, name="A")
monkeypatch.setattr(player, "is_playing", lambda: False)
# Short pause: no nudge.
player._paused_at = time_module.monotonic() - 5
player._local._paused_at = time_module.monotonic() - 5
player.toggle_play()
player._media.setPosition.assert_not_called()
player._local._media.setPosition.assert_not_called()
# Walk-away pause: seek-in-place re-primes the suspended sink.
player._media.position.return_value = 12345
player._paused_at = time_module.monotonic() - 600
player._local._media.position.return_value = 12345
player._local._paused_at = time_module.monotonic() - 600
player.toggle_play()
player._media.setPosition.assert_called_once_with(12345)
assert player._paused_at is None
player._local._media.setPosition.assert_called_once_with(12345)
assert player._local._paused_at is None
# ---- Task B: search bar lives in the header strip ----
+22 -22
View File
@@ -48,21 +48,21 @@ class TestSameSourceReplay:
url = QUrl.fromLocalFile(loc)
player.play_queue([1], 0) # first load: source was empty, plain set
assert player._media.setSource.call_args_list[-1].args == (url,)
assert player._local._media.setSource.call_args_list[-1].args == (url,)
# The track is now the loaded source; the user edits its start time
# and plays it again. The media is already loaded, so the player must
# seek directly instead of waiting for a LoadedMedia that never comes.
player._media.source.return_value = url
player._local._media.source.return_value = url
track.start_time = 30_000
player._media.setSource.reset_mock()
player._local._media.setSource.reset_mock()
player.play_queue([1], 0)
player._media.setSource.assert_not_called()
player._media.stop.assert_called()
player._media.setPosition.assert_called_with(30_000)
assert player._pending_start_ms == 0 # consumed
player._media.play.assert_called()
player._local._media.setSource.assert_not_called()
player._local._media.stop.assert_called()
player._local._media.setPosition.assert_called_with(30_000)
assert player._local._pending_start_ms == 0 # consumed
player._local._media.play.assert_called()
def test_unchanged_url_without_start_time_restarts_at_zero(
self, qapp, tmp_path):
@@ -72,13 +72,13 @@ class TestSameSourceReplay:
player, _ = _player(qapp, [track])
player.play_queue([1], 0)
player._media.source.return_value = QUrl.fromLocalFile(loc)
player._media.setPosition.reset_mock()
player._local._media.source.return_value = QUrl.fromLocalFile(loc)
player._local._media.setPosition.reset_mock()
player.play_queue([1], 0)
# stop() rewinds to 0; no seek should be issued.
player._media.stop.assert_called()
player._media.setPosition.assert_not_called()
player._local._media.stop.assert_called()
player._local._media.setPosition.assert_not_called()
def test_different_url_loads_via_setsource(self, qapp, tmp_path):
for name in ("a.mp3", "b.mp3"):
@@ -87,16 +87,16 @@ class TestSameSourceReplay:
t2 = _track(2, location=str(tmp_path / "b.mp3"),
total_time=200_000, start_time=30_000)
player, _ = _player(qapp, [t1, t2])
player._media.source.return_value = QUrl.fromLocalFile(t1.location)
player._local._media.source.return_value = QUrl.fromLocalFile(t1.location)
player.play_queue([1, 2], 1) # loading b.mp3 while a.mp3 is the source
assert [c.args for c in player._media.setSource.call_args_list] \
assert [c.args for c in player._local._media.setSource.call_args_list] \
== [(QUrl.fromLocalFile(t2.location),)]
# New source: the seek waits for LoadedMedia as before.
assert player._pending_start_ms == 30_000
player._on_media_status(player_module.QMediaPlayer.MediaStatus.LoadedMedia)
player._media.setPosition.assert_called_with(30_000)
assert player._pending_start_ms == 0
assert player._local._pending_start_ms == 30_000
player._local._on_media_status(player_module.QMediaPlayer.MediaStatus.LoadedMedia)
player._local._media.setPosition.assert_called_with(30_000)
assert player._local._pending_start_ms == 0
class TestPreviousRestart:
@@ -107,9 +107,9 @@ class TestPreviousRestart:
player, _ = _player(qapp, [track])
player.play_queue([1], 0)
player._media.position.return_value = RESTART_THRESHOLD_MS + 1
player._local._media.position.return_value = RESTART_THRESHOLD_MS + 1
player.previous()
player._media.setPosition.assert_called_with(30_000)
player._local._media.setPosition.assert_called_with(30_000)
def test_restart_without_start_time_seeks_to_zero(self, qapp, tmp_path):
(tmp_path / "a.mp3").write_bytes(b"x")
@@ -117,6 +117,6 @@ class TestPreviousRestart:
player, _ = _player(qapp, [track])
player.play_queue([1], 0)
player._media.position.return_value = RESTART_THRESHOLD_MS + 1
player._local._media.position.return_value = RESTART_THRESHOLD_MS + 1
player.previous()
player._media.setPosition.assert_called_with(0)
player._local._media.setPosition.assert_called_with(0)
+2 -2
View File
@@ -96,8 +96,8 @@ class TestPlayerLogging:
track = Track(track_id=1, name="Song A",
location=str(tmp_path / "a.mp3"), total_time=100_000)
player = self._player(qapp, [track])
player._media.position.return_value = 4321
player._media.playbackState.return_value = None # != PlayingState
player._local._media.position.return_value = 4321
player._local._media.playbackState.return_value = None # != PlayingState
player.play_queue([1], 0)
player.toggle_play()
text = path.read_text()
+911
View File
@@ -0,0 +1,911 @@
"""Round 29: casting to a Chromecast.
Covers the parts that are verifiable without a device on the network — the
format gate, byte-range arithmetic, and the local media server that hands the
Chromecast its files. The server tests drive a real socket on an ephemeral
port, so range requests, HEAD and token containment are exercised end to end.
Not covered here (hand-verified against a real Chromecast): the pychromecast
handshake and discovery, the receiver's own decode of any given file, seek
behavior over Wi-Fi, and the MainWindow menu/dialog wiring.
"""
import http.client
import socket
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from PyQt6.QtCore import QObject, pyqtSignal
from lintunes import player as player_module
from lintunes.cast.discovery import CastDevice
from lintunes.cast.server import KEEP_TOKENS, TrackServer
from lintunes.cast.sink import (
BACKDROP_APP_ID, DEFAULT_RECEIVER_APP_ID, CastSink, app_stolen,
connection_verdict, duration_ms_from, is_load_error, is_natural_end,
is_playing_state, position_ms_from)
from lintunes.cast.support import (
content_type_for, local_ip_for, parse_range, uncastable_reason)
from lintunes.gui.cast_dialog import ChromecastDialog
from lintunes.gui.cast_indicator import CastIndicator
from lintunes.gui.icons import transport_icon
from lintunes.gui.spinner import Spinner
from lintunes.models import Track
from lintunes.models.library import Library
from lintunes.player import PlaybackSink, Player
# ---- the format gate ----
class TestContentType:
def test_mp3_and_aac_cast_natively(self):
assert content_type_for("/m/a.mp3", "MPEG audio file") == "audio/mpeg"
assert content_type_for("/m/a.mpga") == "audio/mpeg"
assert content_type_for("/m/a.m4a", "Matched AAC audio file") == "audio/mp4"
assert content_type_for("/m/a.m4a", "AAC audio file") == "audio/mp4"
assert content_type_for("/m/a.wav", "WAV audio file") == "audio/wav"
def test_purchased_aac_is_drm_free_and_casts(self):
# iTunes Plus downloads are unencrypted; only "Protected AAC" isn't.
assert content_type_for("/m/a.m4a", "Purchased AAC audio file") == "audio/mp4"
def test_lossless_and_protected_are_refused(self):
assert content_type_for("/m/a.m4a", "Apple Lossless audio file") is None
assert content_type_for("/m/a.m4a", "Protected AAC audio file") is None
assert content_type_for("/m/a.m4p") is None
assert content_type_for("/m/a.aiff", "AIFF audio file") is None
assert content_type_for("/m/a.aif") is None
def test_unknown_container_is_refused(self):
assert content_type_for("/m/a.wma") is None
assert content_type_for("") is None
def test_suffix_case_is_ignored(self):
assert content_type_for("/m/A.MP3") == "audio/mpeg"
def test_reason_names_the_format(self):
assert "Apple Lossless" in uncastable_reason(
"/m/a.m4a", "Apple Lossless audio file")
assert "copy-protected AAC" in uncastable_reason("/m/a.m4p")
assert "AIFF" in uncastable_reason("/m/a.aiff")
assert "WMA" in uncastable_reason("/m/a.wma")
# ---- byte ranges ----
class TestParseRange:
def test_no_header_serves_whole_file(self):
assert parse_range(None, 100) is None
assert parse_range("", 100) is None
def test_closed_range(self):
assert parse_range("bytes=10-19", 100) == (10, 19)
def test_open_ended_range_runs_to_the_end(self):
assert parse_range("bytes=10-", 100) == (10, 99)
def test_suffix_range_takes_the_tail(self):
assert parse_range("bytes=-20", 100) == (80, 99)
assert parse_range("bytes=-500", 100) == (0, 99) # longer than the file
def test_end_past_eof_is_clamped(self):
assert parse_range("bytes=90-999", 100) == (90, 99)
def test_unsatisfiable_and_malformed_fall_back_to_whole_file(self):
# A 200 with the full body is a legal answer to any Range request and
# keeps the device playing instead of erroring it out.
assert parse_range("bytes=100-", 100) is None # start at/past EOF
assert parse_range("bytes=50-10", 100) is None # inverted
assert parse_range("bytes=abc-def", 100) is None
assert parse_range("items=0-10", 100) is None
assert parse_range("bytes=0-10,20-30", 100) is None # multi-range
assert parse_range("bytes=0-10", 0) is None # empty file
# ---- source address selection ----
class TestLocalIp:
def test_returns_the_address_that_routes_to_the_peer(self):
# Route toward a public address; any answer is fine as long as it is a
# real local v4 address and not loopback-by-accident.
ip = local_ip_for("8.8.8.8")
assert ip.count(".") == 3
socket.inet_aton(ip) # raises if it isn't a v4 address
def test_unreachable_peer_still_resolves(self):
# connect() on UDP only picks a route, so an unroutable-but-valid
# address still yields the interface the kernel would use.
assert local_ip_for("192.0.2.1").count(".") == 3
# ---- the media server ----
@pytest.fixture
def served(tmp_path):
"""A running TrackServer plus a helper to talk to it over loopback."""
server = TrackServer()
server.start()
yield server
server.stop()
def _request(server, path, method="GET", headers=None):
conn = http.client.HTTPConnection("127.0.0.1", server.port, timeout=5)
try:
conn.request(method, path, headers=headers or {})
response = conn.getresponse()
return response.status, dict(response.getheaders()), response.read()
finally:
conn.close()
def _audio(tmp_path, name="song.mp3", data=None):
path = tmp_path / name
path.write_bytes(data if data is not None else bytes(range(256)) * 8)
return path
class TestTrackServer:
def test_serves_the_published_file_verbatim(self, served, tmp_path):
path = _audio(tmp_path)
token = served.publish(path, "audio/mpeg")
status, headers, body = _request(served, f"/t/{token}")
assert status == 200
assert body == path.read_bytes()
assert headers["Content-Type"] == "audio/mpeg"
assert headers["Content-Length"] == str(path.stat().st_size)
# Without this the device won't seek and won't report a duration.
assert headers["Accept-Ranges"] == "bytes"
def test_head_reports_size_without_a_body(self, served, tmp_path):
path = _audio(tmp_path)
token = served.publish(path, "audio/mpeg")
status, headers, body = _request(served, f"/t/{token}", method="HEAD")
assert status == 200
assert body == b""
assert headers["Content-Length"] == str(path.stat().st_size)
def test_range_request_returns_the_slice(self, served, tmp_path):
path = _audio(tmp_path)
size = path.stat().st_size
token = served.publish(path, "audio/mpeg")
status, headers, body = _request(
served, f"/t/{token}", headers={"Range": "bytes=10-19"})
assert status == 206
assert body == path.read_bytes()[10:20]
assert headers["Content-Range"] == f"bytes 10-19/{size}"
assert headers["Content-Length"] == "10"
def test_open_ended_range_returns_the_tail(self, served, tmp_path):
path = _audio(tmp_path)
size = path.stat().st_size
token = served.publish(path, "audio/mpeg")
status, headers, body = _request(
served, f"/t/{token}", headers={"Range": "bytes=100-"})
assert status == 206
assert body == path.read_bytes()[100:]
assert headers["Content-Range"] == f"bytes 100-{size - 1}/{size}"
def test_unknown_token_is_not_found(self, served):
status, _, _ = _request(served, "/t/nonexistent")
assert status == 404
def test_no_path_can_be_smuggled_through_the_url(self, served, tmp_path):
# There is no path in the request to traverse with — only a token
# lookup — so these are 404s by construction, not by sanitizing.
_audio(tmp_path)
for probe in ("/t/../../../etc/passwd", "/etc/passwd",
"/t/%2e%2e%2fetc%2fpasswd", "/", f"/t/{tmp_path}"):
status, _, _ = _request(served, probe)
assert status == 404, probe
def test_only_the_most_recent_tokens_stay_live(self, served, tmp_path):
tokens = [served.publish(_audio(tmp_path, f"{i}.mp3"), "audio/mpeg")
for i in range(KEEP_TOKENS + 2)]
for stale in tokens[:2]:
status, _, _ = _request(served, f"/t/{stale}")
assert status == 404
for live in tokens[2:]:
status, _, _ = _request(served, f"/t/{live}")
assert status == 200
def test_previous_track_stays_fetchable(self, served, tmp_path):
# The device re-requests the outgoing file while draining its buffer.
first = served.publish(_audio(tmp_path, "a.mp3"), "audio/mpeg")
served.publish(_audio(tmp_path, "b.mp3"), "audio/mpeg")
status, _, _ = _request(served, f"/t/{first}")
assert status == 200
def test_file_deleted_after_publishing_is_not_found(self, served, tmp_path):
path = _audio(tmp_path)
token = served.publish(path, "audio/mpeg")
path.unlink()
status, _, _ = _request(served, f"/t/{token}")
assert status == 404
def test_a_client_hanging_up_mid_stream_does_not_kill_the_server(
self, served, tmp_path):
# Exactly what the Chromecast does on every seek, pause and stop.
big = _audio(tmp_path, "big.mp3", b"x" * (4 * 1024 * 1024))
token = served.publish(big, "audio/mpeg")
conn = http.client.HTTPConnection("127.0.0.1", served.port, timeout=5)
conn.request("GET", f"/t/{token}")
conn.getresponse().read(1024)
conn.close() # hang up mid-body
# The server thread survived and still answers.
status, _, body = _request(served, f"/t/{token}")
assert status == 200
assert len(body) == big.stat().st_size
def test_url_for_uses_the_bound_port(self, served, tmp_path):
token = served.publish(_audio(tmp_path), "audio/mpeg")
assert served.url_for("192.168.1.5", token) == (
f"http://192.168.1.5:{served.port}/t/{token}")
def test_stop_is_idempotent_and_closes_the_port(self, tmp_path):
server = TrackServer()
port = server.start()
server.publish(_audio(tmp_path), "audio/mpeg")
server.stop()
server.stop() # must not raise
with pytest.raises(OSError):
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=2)
conn.request("GET", "/t/anything")
conn.getresponse()
def test_tokens_do_not_survive_a_restart(self, tmp_path):
server = TrackServer()
server.start()
token = server.publish(_audio(tmp_path), "audio/mpeg")
server.stop()
server.start()
try:
assert server.lookup(token) is None
finally:
server.stop()
# ---- reading the device's status ----
def _status(**fields):
fields.setdefault("player_state", "PLAYING")
fields.setdefault("idle_reason", None)
fields.setdefault("media_session_id", 1)
return SimpleNamespace(**fields)
class TestStatusReading:
def test_finished_is_a_natural_end(self):
assert is_natural_end(_status(player_state="IDLE", idle_reason="FINISHED"))
def test_other_idle_reasons_are_not_a_finish(self):
# Counting a play or advancing on these would be wrong: ERROR means it
# never played, INTERRUPTED means something else took the device.
for reason in ("ERROR", "INTERRUPTED", "CANCELLED", None):
assert not is_natural_end(
_status(player_state="IDLE", idle_reason=reason))
assert is_load_error(_status(player_state="IDLE", idle_reason="ERROR"))
def test_buffering_still_counts_as_playing(self):
# Otherwise the play button flickers every time the device re-buffers.
assert is_playing_state(_status(player_state="BUFFERING"))
assert is_playing_state(_status(player_state="PLAYING"))
assert not is_playing_state(_status(player_state="PAUSED"))
def test_position_extrapolates_only_while_playing(self):
playing = _status(player_state="PLAYING",
adjusted_current_time=12.5, current_time=10.0)
assert position_ms_from(playing, 0) == 12_500
# adjusted_current_time keeps creeping off the wall clock while
# paused, which would drift the seek slider during a pause.
paused = _status(player_state="PAUSED",
adjusted_current_time=99.0, current_time=10.0)
assert position_ms_from(paused, 0) == 10_000
def test_position_falls_back_when_the_device_says_nothing(self):
assert position_ms_from(None, 4321) == 4321
assert position_ms_from(
_status(adjusted_current_time=None, current_time=None), 4321) == 4321
def test_duration_falls_back_to_the_library_value(self):
assert duration_ms_from(_status(duration=180.0), 0) == 180_000
assert duration_ms_from(_status(duration=None), 200_000) == 200_000
assert duration_ms_from(None, 200_000) == 200_000
def test_foreign_receiver_reads_as_stolen(self):
assert app_stolen("YouTube")
assert not app_stolen(DEFAULT_RECEIVER_APP_ID)
assert not app_stolen(BACKDROP_APP_ID)
assert not app_stolen(None)
assert not app_stolen("")
def test_lost_is_a_grace_period_not_a_disconnect(self):
# pychromecast retries a dropped socket on its own, so a Wi-Fi blip
# must not dump playback back to the laptop speakers.
assert connection_verdict("LOST") == "grace"
assert connection_verdict("CONNECTING") == "grace"
assert connection_verdict("CONNECTED") == "ok"
assert connection_verdict("DISCONNECTED") == "lost"
assert connection_verdict("FAILED") == "lost"
assert connection_verdict("FAILED_RESOLVE") == "lost"
# ---- Player's sink contract ----
class _FakeSink(PlaybackSink):
"""A sink that records what Player asked of it, standing in for a cast."""
def __init__(self, playable=True, parent=None):
super().__init__(parent)
self.name = "Kitchen"
self.loads = []
self.volume = None
self.stopped = 0
self.shutdowns = 0
self._playing = False
self._position = 0
self._playable = playable
def load(self, track, autoplay, start_ms):
self.loads.append((track.track_id, autoplay, start_ms))
self._playing = autoplay
self._position = start_ms
def play(self):
self._playing = True
def pause(self):
self._playing = False
def stop(self):
self.stopped += 1
self._playing = False
def seek(self, position_ms):
self._position = position_ms
def position_ms(self):
return self._position
def duration_ms(self):
return 0
def is_playing(self):
return self._playing
def set_volume(self, level):
self.volume = level
def can_play(self, track):
return self._playable
def unplayable_reason(self, track):
return "a Chromecast can't play Apple Lossless"
def shutdown(self):
self.shutdowns += 1
def _player(qapp, tracks):
library = Library()
for track in tracks:
library.tracks[track.track_id] = track
with patch.multiple(
player_module,
QMediaPlayer=MagicMock(),
QAudioOutput=MagicMock(),
QAudioBufferOutput=MagicMock(),
QMediaDevices=MagicMock(),
):
return Player(MagicMock(library=library))
def _local_track(tmp_path, track_id, name="Song", **fields):
path = tmp_path / f"{track_id}.mp3"
path.write_bytes(b"x")
return Track(track_id=track_id, name=name, location=str(path), **fields)
class TestPlayerSinks:
def test_default_sink_is_local(self, qapp):
player = _player(qapp, [])
assert player.active_sink() is player._local
assert player.provides_pcm() is True
def test_swap_carries_track_position_and_playing_state(self, qapp, tmp_path):
track = _local_track(tmp_path, 1, total_time=200_000)
player = _player(qapp, [track])
player.play_queue([1], 0)
player._local._media.position.return_value = 42_000
player._local._media.playbackState.return_value = \
player_module.QMediaPlayer.PlaybackState.PlayingState
sink = _FakeSink()
player.set_sink(sink)
assert player.active_sink() is sink
assert sink.loads == [(1, True, 42_000)] # same song, same spot
assert player.provides_pcm() is False
def test_swap_does_not_re_announce_the_track(self, qapp, tmp_path):
# A second track_changed would fire a duplicate Last.fm now-playing
# and reset an in-progress bpm tap, for what is the same song.
track = _local_track(tmp_path, 1)
player = _player(qapp, [track])
player.play_queue([1], 0)
seen = []
player.track_changed.connect(seen.append)
player.set_sink(_FakeSink())
assert seen == []
def test_volume_follows_the_swap_both_ways(self, qapp):
player = _player(qapp, [])
player.set_volume(0.42)
sink = _FakeSink()
player.set_sink(sink)
assert sink.volume == 0.42 # the slider keeps its meaning
player.set_volume(0.7)
assert sink.volume == 0.7
player.set_sink(None)
assert player.volume() == 0.7
assert player.active_sink() is player._local
def test_swapping_to_the_same_sink_is_a_no_op(self, qapp):
player = _player(qapp, [])
sink = _FakeSink()
player.set_sink(sink)
player.set_sink(sink)
assert sink.stopped == 0
def test_natural_end_on_a_cast_records_the_play_and_advances(
self, qapp, tmp_path):
t1 = _local_track(tmp_path, 1, total_time=200_000)
t2 = _local_track(tmp_path, 2, total_time=200_000)
player = _player(qapp, [t1, t2])
finished = []
player.track_finished.connect(lambda t: finished.append(t.track_id))
player.play_queue([1, 2], 0)
sink = _FakeSink()
player.set_sink(sink)
sink.ended.emit()
player._manager.record_play.assert_called_once_with(1)
assert finished == [1]
assert player.current_track.track_id == 2
def test_repeated_end_counts_the_play_once(self, qapp, tmp_path):
track = _local_track(tmp_path, 1, total_time=200_000)
player = _player(qapp, [track])
player.play_queue([1], 0)
sink = _FakeSink()
player.set_sink(sink)
sink.ended.emit()
sink.ended.emit()
player._manager.record_play.assert_called_once_with(1)
def test_custom_stop_time_still_ends_early_on_a_cast(self, qapp, tmp_path):
t1 = _local_track(tmp_path, 1, total_time=200_000, stop_time=60_000)
t2 = _local_track(tmp_path, 2, total_time=200_000)
player = _player(qapp, [t1, t2])
player.play_queue([1, 2], 0)
sink = _FakeSink()
player.set_sink(sink)
sink.position_changed.emit(59_000)
assert player._manager.record_play.call_count == 0
sink.position_changed.emit(60_500)
player._manager.record_play.assert_called_once_with(1)
assert player.current_track.track_id == 2
def test_custom_start_time_reaches_the_sink(self, qapp, tmp_path):
track = _local_track(tmp_path, 1, total_time=200_000, start_time=30_000)
player = _player(qapp, [track])
sink = _FakeSink()
player.set_sink(sink)
player.play_queue([1], 0)
assert sink.loads[-1] == (1, True, 30_000)
def test_unplayable_track_is_skipped_not_dialogged(self, qapp, tmp_path):
t1 = _local_track(tmp_path, 1, name="Lossless")
t2 = _local_track(tmp_path, 2, name="Fine")
player = _player(qapp, [t1, t2])
skipped, errors = [], []
player.track_unplayable.connect(
lambda track, reason: skipped.append((track.name, reason)))
# error_occurred opens a modal dialog in the GUI — the wrong channel
# for "this one track's format isn't supported".
player.error_occurred.connect(errors.append)
sink = _FakeSink()
# Only the second track is castable.
sink.can_play = lambda track: track.track_id == 2
player.set_sink(sink)
player.play_queue([1, 2], 0)
assert [name for name, _ in skipped] == ["Lossless"]
assert "Apple Lossless" in skipped[0][1]
assert errors == []
assert player.current_track.track_id == 2
def test_a_queue_of_unplayable_tracks_stops_without_recursing(
self, qapp, tmp_path):
tracks = [_local_track(tmp_path, i) for i in range(1, 600)]
player = _player(qapp, tracks)
skipped = []
player.track_unplayable.connect(lambda t, r: skipped.append(t))
player.set_sink(_FakeSink(playable=False))
player.play_queue([t.track_id for t in tracks], 0)
assert len(skipped) == len(tracks)
assert player.current_track is None # stopped cleanly, no crash
def test_a_dead_sink_falls_back_to_local_playback(self, qapp, tmp_path):
track = _local_track(tmp_path, 1, total_time=200_000)
player = _player(qapp, [track])
player.play_queue([1], 0)
sink = _FakeSink()
player.set_sink(sink)
sink._position = 55_000
sink._playing = True
errors = []
player.error_occurred.connect(errors.append)
sink.unavailable.emit("Lost the connection to Kitchen.")
assert player.active_sink() is player._local
assert errors == ["Lost the connection to Kitchen."]
def test_missing_file_still_reports_on_a_cast_sink(self, qapp, tmp_path):
track = Track(track_id=1, name="Ghost",
location=str(tmp_path / "gone" / "a.mp3"))
player = _player(qapp, [track])
missing = []
player.track_missing.connect(missing.append)
player.set_sink(_FakeSink())
player.play_queue([1], 0)
assert missing == [track]
assert player.current_track is None
def test_shutdown_tears_down_both_sinks(self, qapp):
player = _player(qapp, [])
sink = _FakeSink()
player.set_sink(sink)
player.shutdown()
assert sink.shutdowns == 1
# The local sink is kept alive across a swap, so its Qt Multimedia
# pipeline still needs the ordered teardown that avoids a segfault.
player._local._media.setAudioOutput.assert_called_with(None)
# ---- the cast sink's failure handling ----
class _FakeCast:
"""Enough of a pychromecast Chromecast to build a CastSink against."""
def __init__(self):
self.cast_info = SimpleNamespace(friendly_name="Kitchen", host="10.0.0.5")
self.status = SimpleNamespace(volume_level=0.8)
self.media_controller = SimpleNamespace(
status=None, register_status_listener=lambda _l: None,
play_media=lambda *a, **k: None, play=lambda: None,
pause=lambda: None, stop=lambda: None, seek=lambda _s: None)
self.volumes = []
self.quit_calls = 0
self.disconnects = 0
def register_status_listener(self, listener):
pass
def register_connection_listener(self, listener):
pass
def set_volume(self, level):
self.volumes.append(level)
def quit_app(self):
self.quit_calls += 1
def disconnect(self):
self.disconnects += 1
@pytest.fixture
def cast_sink(qapp):
cast = _FakeCast()
server = TrackServer()
sink = CastSink(cast, server)
yield sink, cast
sink.shutdown()
class TestCastSinkFailure:
def test_failure_reports_the_state_lintunes_asked_for(self, cast_sink):
sink, _cast = cast_sink
sink._intent_playing = True
sink._playing = True
# A dying connection pushes an IDLE media status just before the
# disconnect. Believing it here would stop the music on the fallback.
sink._on_status(_status(player_state="IDLE", idle_reason=None))
assert sink.is_playing() is False
reasons = []
sink.unavailable.connect(reasons.append)
sink._on_connection("DISCONNECTED")
assert reasons == ["Lost the connection to Kitchen."]
assert sink.is_playing() is True # so Player resumes locally playing
def test_a_paused_session_does_not_start_playing_on_failure(self, cast_sink):
sink, _cast = cast_sink
sink._intent_playing = False
sink._on_connection("DISCONNECTED")
assert sink.is_playing() is False
def test_failure_fires_once(self, cast_sink):
sink, _cast = cast_sink
reasons = []
sink.unavailable.connect(reasons.append)
sink._on_connection("DISCONNECTED")
sink._on_connection("FAILED")
sink._on_grace_expired()
assert len(reasons) == 1
def test_a_dropped_socket_gets_a_grace_period(self, cast_sink):
sink, _cast = cast_sink
reasons = []
sink.unavailable.connect(reasons.append)
sink._on_connection("LOST")
assert sink._lost_timer.isActive()
assert reasons == [] # pychromecast is still retrying
sink._on_connection("CONNECTED")
assert not sink._lost_timer.isActive()
assert reasons == [] # the blip healed itself
def test_grace_expiring_gives_up(self, cast_sink):
sink, _cast = cast_sink
reasons = []
sink.unavailable.connect(reasons.append)
sink._on_connection("LOST")
sink._on_grace_expired()
assert reasons == ["Kitchen stopped responding."]
def test_another_app_taking_the_device_ends_the_session(self, cast_sink):
sink, _cast = cast_sink
reasons = []
sink.unavailable.connect(reasons.append)
sink._on_app(DEFAULT_RECEIVER_APP_ID)
sink._on_app(BACKDROP_APP_ID)
assert reasons == []
sink._on_app("YouTube")
assert reasons == ["Something else started casting to Kitchen."]
def test_natural_end_is_reported_once_per_session(self, cast_sink):
sink, _cast = cast_sink
ends = []
sink.ended.connect(lambda: ends.append(True))
done = _status(player_state="IDLE", idle_reason="FINISHED",
media_session_id=7)
sink._on_status(done)
sink._on_status(done) # the device repeats its final status
assert ends == [True]
def test_shutdown_hands_the_device_volume_back(self, qapp):
cast = _FakeCast()
sink = CastSink(cast, TrackServer())
sink.set_volume(0.2)
sink.shutdown()
sink.shutdown() # idempotent
# The device's own volume persists for whatever plays next, so the
# session's setting must not be left behind.
assert cast.volumes[-1] == 0.8
assert cast.quit_calls == 1
assert cast.disconnects == 1
def test_uncastable_track_is_refused_by_the_sink(self, cast_sink):
sink, _cast = cast_sink
alac = Track(track_id=1, name="Lossless", location="/m/a.m4a",
kind="Apple Lossless audio file")
mp3 = Track(track_id=2, name="Fine", location="/m/a.mp3")
assert not sink.can_play(alac)
assert "Apple Lossless" in sink.unplayable_reason(alac)
assert sink.can_play(mp3)
# ---- widgets ----
class _FakeDiscovery(QObject):
device_found = pyqtSignal(object)
device_lost = pyqtSignal(object)
failed = pyqtSignal(str)
def __init__(self):
super().__init__()
self.started = 0
self.stopped = 0
def devices(self):
return []
def start(self):
self.started += 1
def stop(self):
self.stopped += 1
def _device(uuid="u1", name="Kitchen", model="Chromecast Audio"):
return CastDevice(uuid=uuid, name=name, model=model,
host="10.0.0.5", port=8009)
class TestCastIndicator:
def test_holds_its_space_when_idle(self, qapp):
indicator = CastIndicator()
indicator.show()
# Hiding it would collapse the layout and shift the volume slider the
# moment you connect — idle is a visible widget with no icon.
assert indicator.icon().isNull()
assert not indicator.isEnabled()
idle_height = indicator.sizeHint().height()
indicator.set_connected(False)
assert not indicator.isHidden() # never hides itself
indicator.set_connected(True, "Kitchen")
assert not indicator.icon().isNull()
assert indicator.isEnabled()
assert "Kitchen" in indicator.toolTip()
assert indicator.sizeHint().height() == idle_height
indicator.set_connected(False)
assert indicator.icon().isNull()
class TestTransportLayout:
def test_cast_indicator_does_not_move_the_volume_slider(
self, qapp, tmp_path):
from lintunes.gui.transport import BAR_HEIGHT, TransportBar
from lintunes.preferences import Preferences
player = _player(qapp, [])
bar = TransportBar(player, MagicMock(), Preferences(tmp_path))
bar.resize(1200, BAR_HEIGHT)
bar.show()
idle_y = bar._volume_slider.pos().y()
bar._cast_indicator.set_connected(True, "Kitchen")
bar.layout().activate()
assert bar._volume_slider.pos().y() == idle_y
# The whole column has to stay inside the bar's documented height.
assert bar.sizeHint().height() <= BAR_HEIGHT
bar.hide()
class TestCastDialog:
def test_devices_appear_and_disappear(self, qapp):
discovery = _FakeDiscovery()
dialog = ChromecastDialog(discovery)
assert discovery.started == 1
discovery.device_found.emit(_device())
assert dialog._list.count() == 1
assert "Kitchen" in dialog._list.item(0).text()
# zeroconf re-announces constantly; the same device must not stack up.
discovery.device_found.emit(_device())
assert dialog._list.count() == 1
discovery.device_lost.emit(_device())
assert dialog._list.count() == 0
def test_connect_needs_a_selection(self, qapp):
discovery = _FakeDiscovery()
dialog = ChromecastDialog(discovery)
assert not dialog._connect_button.isEnabled()
discovery.device_found.emit(_device())
assert dialog._connect_button.isEnabled()
dialog._on_connect()
assert dialog.selected_device.name == "Kitchen"
def test_closing_always_stops_the_search(self, qapp):
for close in (lambda d: d.reject(), lambda d: d.accept(),
lambda d: d.done(0)):
discovery = _FakeDiscovery()
dialog = ChromecastDialog(discovery)
close(dialog)
assert discovery.stopped == 1
def test_missing_dependency_is_reported_not_spun_forever(self, qapp):
discovery = _FakeDiscovery()
dialog = ChromecastDialog(discovery)
discovery.failed.emit("Chromecast support isn't installed.")
assert "isn't installed" in dialog._status.text()
assert dialog._spinner.isHidden()
class TestSpinner:
def test_animates_only_while_visible(self, qapp):
spinner = Spinner()
spinner.show()
assert spinner._timer.isActive()
spinner.hide()
assert not spinner._timer.isActive()
class TestCastIcons:
def test_cast_glyphs_render_and_differ(self, qapp):
from PyQt6.QtGui import QColor
idle = transport_icon("cast", QColor("#4A4A4A"))
live = transport_icon("cast_connected", QColor("#4A4A4A"))
assert not idle.isNull() and not live.isNull()
assert (idle.pixmap(20, 20).toImage()
!= live.pixmap(20, 20).toImage())
class TestVisualizerWhileCasting:
def test_stops_animating_when_the_sink_has_no_pcm(self, qapp, tmp_path):
from lintunes.gui.visualizer import VisualizerWidget
from lintunes.preferences import Preferences
player = _player(qapp, [])
vis = VisualizerWidget(player, Preferences(tmp_path))
player.set_sink(_FakeSink())
assert vis._no_pcm is True
assert not vis._timer.isActive()
# A play/pause must not restart an FFT over a buffer of zeros.
player.playing_changed.emit(True)
assert not vis._timer.isActive()
player.set_sink(None)
assert vis._no_pcm is False
+4 -4
View File
@@ -248,7 +248,7 @@ class TestPlayerStartStop:
total_time=200_000, start_time=30_000)
player, _ = self._player(qapp, [track])
player.play_queue([1], 0)
assert player._pending_start_ms == 30_000
player._on_media_status(player_module.QMediaPlayer.MediaStatus.LoadedMedia)
player._media.setPosition.assert_called_with(30_000)
assert player._pending_start_ms == 0 # consumed (one-shot)
assert player._local._pending_start_ms == 30_000
player._local._on_media_status(player_module.QMediaPlayer.MediaStatus.LoadedMedia)
player._local._media.setPosition.assert_called_with(30_000)
assert player._local._pending_start_ms == 0 # consumed (one-shot)