Playing a track with an iTunes start time (e.g. "Pretty Girls is a Motherfucker", 0:40) began at 0:00 whenever another track was already loaded. QMediaPlayer.setSource() synchronously emits two status changes before returning: a LoadedMedia still reporting the *outgoing* source, then LoadingMedia for the new one. The stale first event consumed the one-shot _pending_start_ms armed in Round 22 and seeked the dying pipeline, so the new track's real LoadedMedia ~5 ms later found nothing armed. Only the first track after launch worked. The armed seek now carries the URL it belongs to and is consumed only when source() matches. tests/test_round32.py models the real Qt event sequence, which Round 22's plain MagicMock never emitted; the test_round8/test_round22 stubs now report the new source by the time its LoadedMedia arrives, as Qt does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
127 lines
5.3 KiB
Python
127 lines
5.3 KiB
Python
"""Round 22: start-time fixes in the player.
|
|
|
|
Editing a track's start time saved fine but replaying the *already loaded*
|
|
track ignored it: QMediaPlayer.setSource() no-ops on an unchanged URL, so the
|
|
LoadedMedia status change that consumes the armed start time never re-fired
|
|
(and forcing a clear+reload races the FFmpeg backend, which snaps the seek
|
|
back to 0). _load_current now detects the unchanged source and seeks directly
|
|
— the media is already loaded. Also, "previous"-restart now rewinds to the
|
|
custom start time instead of 0:00.
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from PyQt6.QtCore import QUrl
|
|
|
|
from lintunes.models import Library, Track
|
|
from lintunes import player as player_module
|
|
from lintunes.player import Player, RESTART_THRESHOLD_MS
|
|
|
|
|
|
def _track(tid, **kw):
|
|
return Track(track_id=tid, name=kw.pop("name", f"Track {tid}"), **kw)
|
|
|
|
|
|
def _player(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(),
|
|
):
|
|
player = Player(manager)
|
|
return player, manager
|
|
|
|
|
|
class TestSameSourceReplay:
|
|
def test_unchanged_url_seeks_to_freshly_edited_start(self, qapp, tmp_path):
|
|
(tmp_path / "a.mp3").write_bytes(b"x")
|
|
loc = str(tmp_path / "a.mp3")
|
|
track = _track(1, location=loc, total_time=200_000)
|
|
player, _ = _player(qapp, [track])
|
|
url = QUrl.fromLocalFile(loc)
|
|
|
|
player.play_queue([1], 0) # first load: source was empty, plain set
|
|
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._local._media.source.return_value = url
|
|
track.start_time = 30_000
|
|
player._local._media.setSource.reset_mock()
|
|
player.play_queue([1], 0)
|
|
|
|
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):
|
|
(tmp_path / "a.mp3").write_bytes(b"x")
|
|
loc = str(tmp_path / "a.mp3")
|
|
track = _track(1, location=loc, total_time=200_000)
|
|
player, _ = _player(qapp, [track])
|
|
|
|
player.play_queue([1], 0)
|
|
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._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"):
|
|
(tmp_path / name).write_bytes(b"x")
|
|
t1 = _track(1, location=str(tmp_path / "a.mp3"), total_time=200_000)
|
|
t2 = _track(2, location=str(tmp_path / "b.mp3"),
|
|
total_time=200_000, start_time=30_000)
|
|
player, _ = _player(qapp, [t1, t2])
|
|
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._local._media.setSource.call_args_list] \
|
|
== [(QUrl.fromLocalFile(t2.location),)]
|
|
# New source: the seek waits for LoadedMedia as before.
|
|
assert player._local._pending_start_ms == 30_000
|
|
# Qt reports the new source by the time its LoadedMedia arrives; since
|
|
# Round 32 the armed seek is only consumed when the two match (see
|
|
# tests/test_round32.py for the stale-event sequence that motivated it).
|
|
player._local._media.source.return_value = QUrl.fromLocalFile(t2.location)
|
|
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:
|
|
def test_restart_seeks_to_custom_start_time(self, qapp, tmp_path):
|
|
(tmp_path / "a.mp3").write_bytes(b"x")
|
|
track = _track(1, location=str(tmp_path / "a.mp3"),
|
|
total_time=200_000, start_time=30_000)
|
|
player, _ = _player(qapp, [track])
|
|
player.play_queue([1], 0)
|
|
|
|
player._local._media.position.return_value = RESTART_THRESHOLD_MS + 1
|
|
player.previous()
|
|
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")
|
|
track = _track(1, location=str(tmp_path / "a.mp3"), total_time=200_000)
|
|
player, _ = _player(qapp, [track])
|
|
player.play_queue([1], 0)
|
|
|
|
player._local._media.position.return_value = RESTART_THRESHOLD_MS + 1
|
|
player.previous()
|
|
player._local._media.setPosition.assert_called_with(0)
|