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:
@@ -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
|
||||
Reference in New Issue
Block a user