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>
106 lines
4.0 KiB
Python
106 lines
4.0 KiB
Python
"""Round 23: playback-control provenance log.
|
|
|
|
Every pathway that can start/stop playback now drops a one-line record via
|
|
lintunes.eventlog (MPRIS methods with D-Bus sender, the media-key
|
|
eventFilter with source device, Player transport methods with position), so
|
|
"the app played by itself at night" incidents are attributable after the
|
|
fact from ~/.cache/lintunes/control-events.log.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from lintunes import eventlog
|
|
from lintunes.models import Library, Track
|
|
from lintunes import player as player_module
|
|
from lintunes.player import Player
|
|
|
|
|
|
def _patch_log(monkeypatch, tmp_path):
|
|
path = tmp_path / "control-events.log"
|
|
monkeypatch.setattr(eventlog, "_log_path", lambda: path)
|
|
return path
|
|
|
|
|
|
class TestEventLog:
|
|
def test_writes_timestamped_line(self, monkeypatch, tmp_path):
|
|
path = _patch_log(monkeypatch, tmp_path)
|
|
eventlog.log_control("test", "action", "k=v")
|
|
line = path.read_text().strip()
|
|
assert line.endswith("[test] action k=v")
|
|
assert line[:4].isdigit() # starts with a date
|
|
|
|
def test_detail_optional(self, monkeypatch, tmp_path):
|
|
path = _patch_log(monkeypatch, tmp_path)
|
|
eventlog.log_control("test", "solo")
|
|
assert path.read_text().strip().endswith("[test] solo")
|
|
|
|
def test_rotates_when_large(self, monkeypatch, tmp_path):
|
|
path = _patch_log(monkeypatch, tmp_path)
|
|
path.write_bytes(b"x" * (eventlog._MAX_BYTES + 1))
|
|
eventlog.log_control("test", "rotated")
|
|
assert path.with_suffix(".log.old").exists()
|
|
assert "rotated" in path.read_text()
|
|
|
|
def test_never_raises_on_io_error(self, monkeypatch):
|
|
monkeypatch.setattr(eventlog, "_log_path",
|
|
lambda: type("P", (), {})()) # broken path object
|
|
eventlog.log_control("test", "no-crash") # must not raise
|
|
|
|
|
|
class TestMprisLogging:
|
|
def test_playpause_logs_and_forwards(self, qapp, monkeypatch, tmp_path):
|
|
path = _patch_log(monkeypatch, tmp_path)
|
|
from lintunes.mpris import MprisService
|
|
player = MagicMock()
|
|
service = MprisService(player, MagicMock())
|
|
service.player_adaptor.PlayPause()
|
|
player.toggle_play.assert_called_once()
|
|
text = path.read_text()
|
|
assert "[mpris] PlayPause" in text
|
|
|
|
def test_all_methods_log(self, qapp, monkeypatch, tmp_path):
|
|
path = _patch_log(monkeypatch, tmp_path)
|
|
from lintunes.mpris import MprisService
|
|
service = MprisService(MagicMock(), MagicMock())
|
|
adaptor = service.player_adaptor
|
|
adaptor.Play()
|
|
adaptor.Pause()
|
|
adaptor.Next()
|
|
adaptor.Previous()
|
|
adaptor.Stop()
|
|
text = path.read_text()
|
|
for member in ("Play", "Pause", "Next", "Previous", "Stop"):
|
|
assert f"[mpris] {member}" in text
|
|
|
|
|
|
class TestPlayerLogging:
|
|
def _player(self, qapp, tracks):
|
|
library = Library()
|
|
for t in tracks:
|
|
library.tracks[t.track_id] = t
|
|
manager = MagicMock()
|
|
manager.library = library
|
|
with patch.multiple(
|
|
player_module,
|
|
QMediaPlayer=MagicMock(),
|
|
QAudioOutput=MagicMock(),
|
|
QAudioBufferOutput=MagicMock(),
|
|
QMediaDevices=MagicMock(),
|
|
):
|
|
return Player(manager)
|
|
|
|
def test_toggle_play_logs_state_and_position(self, qapp, monkeypatch,
|
|
tmp_path):
|
|
path = _patch_log(monkeypatch, tmp_path)
|
|
(tmp_path / "a.mp3").write_bytes(b"x")
|
|
track = Track(track_id=1, name="Song A",
|
|
location=str(tmp_path / "a.mp3"), total_time=100_000)
|
|
player = self._player(qapp, [track])
|
|
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()
|
|
assert "[player] toggle_play" in text
|
|
assert "pos=4321ms" in text and "'Song A'" in text
|