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>
108 lines
3.6 KiB
Python
108 lines
3.6 KiB
Python
from unittest.mock import MagicMock, patch
|
|
|
|
from lintunes.models import Track
|
|
from lintunes.models.library import Library
|
|
from lintunes import player as player_module
|
|
from lintunes.player import Player
|
|
|
|
|
|
class FakeManager:
|
|
"""Minimal stand-in for LibraryManager: the Player reads `library.tracks`,
|
|
calls `record_play` on natural end-of-media, and `set_track_location` when
|
|
the user relocates a moved/renamed file."""
|
|
|
|
def __init__(self, library):
|
|
self.library = library
|
|
self.played = []
|
|
|
|
def record_play(self, track_id):
|
|
self.played.append(track_id)
|
|
|
|
def set_track_location(self, track_id, location):
|
|
self.library.tracks[track_id].location = location
|
|
|
|
|
|
def _make_player(qapp, tracks):
|
|
library = Library()
|
|
for track in tracks:
|
|
library.tracks[track.track_id] = track
|
|
# The Qt multimedia backend (QMediaPlayer/QAudioOutput/QMediaDevices)
|
|
# blocks on init in a headless environment, so stub it out — the
|
|
# missing-file path under test never reaches real playback anyway.
|
|
with patch.multiple(
|
|
player_module,
|
|
QMediaPlayer=MagicMock(),
|
|
QAudioOutput=MagicMock(),
|
|
QAudioBufferOutput=MagicMock(),
|
|
QMediaDevices=MagicMock(),
|
|
):
|
|
player = Player(FakeManager(library))
|
|
errors, missing = [], []
|
|
player.error_occurred.connect(errors.append)
|
|
player.track_missing.connect(missing.append)
|
|
return player, errors, missing
|
|
|
|
|
|
def test_missing_file_emits_track_missing_and_stops(qapp, tmp_path):
|
|
location = str(tmp_path / "not-mounted" / "song.mp3")
|
|
track = Track(track_id=1, name="Ghost Song", location=location)
|
|
player, errors, missing = _make_player(qapp, [track])
|
|
|
|
player.play_queue([1], 0)
|
|
|
|
# Routed to track_missing (so the UI can offer "Locate File…"), not the
|
|
# generic error dialog.
|
|
assert errors == []
|
|
assert missing == [track]
|
|
# Stopped, not advanced into a track; setSource was never called.
|
|
assert player.current_track is None
|
|
player._local._media.setSource.assert_not_called()
|
|
|
|
|
|
def test_all_missing_queue_does_not_recurse(qapp, tmp_path):
|
|
# A whole-library queue with every file gone (drive unmounted) must not
|
|
# cascade through the queue and blow the recursion limit.
|
|
tracks = [
|
|
Track(track_id=i, name=f"T{i}", location=str(tmp_path / f"{i}.mp3"))
|
|
for i in range(5000)
|
|
]
|
|
player, errors, missing = _make_player(qapp, tracks)
|
|
|
|
player.play_queue([t.track_id for t in tracks], 0)
|
|
|
|
# One alert for the track we tried to play, then a clean stop.
|
|
assert len(missing) == 1
|
|
assert player.current_track is None
|
|
|
|
|
|
def test_track_not_in_library_emits_error(qapp):
|
|
# Queue references an id that isn't in the library: nothing to locate, so
|
|
# this stays a plain error (not track_missing).
|
|
player, errors, missing = _make_player(qapp, [])
|
|
|
|
player.play_queue([99], 0)
|
|
|
|
assert missing == []
|
|
assert len(errors) == 1
|
|
assert player.current_track is None
|
|
|
|
|
|
def test_retry_current_plays_after_relocate(qapp, tmp_path):
|
|
real = tmp_path / "real.mp3"
|
|
real.write_bytes(b"audio")
|
|
track = Track(track_id=1, name="Moved Song",
|
|
location=str(tmp_path / "gone.mp3"))
|
|
player, errors, missing = _make_player(qapp, [track])
|
|
|
|
player.play_queue([1], 0)
|
|
assert missing == [track] and player.current_track is None
|
|
|
|
# User relocates the file, then the UI retries the same queue position.
|
|
player._manager.set_track_location(1, str(real))
|
|
missing.clear()
|
|
player.retry_current()
|
|
|
|
assert errors == [] and missing == []
|
|
assert player.current_track is track
|
|
player._local._media.setSource.assert_called_once()
|