A paused or stopped QMediaPlayer on Qt's FFmpeg backend keeps its PipeWire stream open and never corks or drains it, so the few seconds the backend decoded ahead sit there live. A later audio-graph change — a USB DAC waking from idle suspend, a device appearing — flushes that stale buffer to the speakers, hours after the app was last touched. That is the "LinTunes plays by itself" haunting: the v0.1.4 provenance log recorded zero control events between the 17:48 pause and the 00:30 incident, while MPRIS still reported Paused at exactly 6974000us — the position it was paused at seven hours earlier — and PipeWire showed the stream state=running. It explains the whole shape of it: always a short burst (only what was buffered), always mid-song, never a recorded play count. After 30s idle, release the pipeline: stop() then setSource(QUrl()), which removes the PipeWire node outright, so no buffer survives to leak. Gain drops to zero first as an independent second layer. stop() arms the brake too — Qt leaves the source loaded there, and _advance() takes that path when a playlist runs out. Resume rebuilds the source and seeks back via the existing custom-start-time machinery. Verified end-to-end against the real LocalSink with a silent WAV, watched through pw-dump: present while playing, still present right after pause, gone once parked, rebuilt on resume with position and duration intact. This is a workaround for an upstream Qt Multimedia bug; TASKS.md now carries the assessment for moving the local sink to GStreamer, which gets correct pause behavior for free and would unlock the parked equalizer and gapless.
188 lines
6.6 KiB
Python
188 lines
6.6 KiB
Python
"""Round 34 — the parking brake.
|
|
|
|
A paused/stopped QMediaPlayer keeps its PipeWire stream alive, still holding
|
|
the audio the FFmpeg backend decoded ahead; a later audio-graph change flushes
|
|
that buffer to the speakers hours afterwards (see PARK_AFTER_IDLE_S). These
|
|
cover the release, the resume, and the ways the release must NOT fire.
|
|
"""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from PyQt6.QtCore import QUrl
|
|
from PyQt6.QtMultimedia import QMediaPlayer
|
|
|
|
from lintunes import player as player_module
|
|
from lintunes.player import LocalSink
|
|
from lintunes.models import Track
|
|
|
|
|
|
def _sink(qapp):
|
|
with patch.multiple(
|
|
player_module,
|
|
QMediaPlayer=MagicMock(),
|
|
QAudioOutput=MagicMock(),
|
|
QAudioBufferOutput=MagicMock(),
|
|
QMediaDevices=MagicMock(),
|
|
):
|
|
return LocalSink()
|
|
|
|
|
|
def _loaded(qapp, url="file:///music/a.mp3", position=6974, duration=180000):
|
|
"""A sink whose media object looks like a paused, loaded track."""
|
|
sink = _sink(qapp)
|
|
sink._media.source.return_value = QUrl(url)
|
|
sink._media.position.return_value = position
|
|
sink._media.duration.return_value = duration
|
|
sink._media.playbackState.return_value = (
|
|
QMediaPlayer.PlaybackState.PausedState)
|
|
return sink
|
|
|
|
|
|
class TestParking:
|
|
def test_park_releases_the_source(self, qapp):
|
|
"""Clearing the source is what actually drops the PipeWire stream."""
|
|
sink = _loaded(qapp)
|
|
sink._park()
|
|
sink._media.stop.assert_called_once()
|
|
cleared = sink._media.setSource.call_args[0][0]
|
|
assert cleared.isEmpty(), "source must be cleared, not just stopped"
|
|
assert sink._parked
|
|
|
|
def test_park_silences_first(self, qapp):
|
|
"""Belt as well as braces: gain hits zero before the teardown, so a
|
|
buffer that somehow survives drains inaudibly."""
|
|
sink = _loaded(qapp)
|
|
sink.set_volume(0.8)
|
|
sink._audio.setVolume.reset_mock()
|
|
sink._park()
|
|
assert sink._audio.setVolume.call_args_list[0][0][0] == 0.0
|
|
|
|
def test_parked_volume_slider_cannot_re_arm_the_gain(self, qapp):
|
|
sink = _loaded(qapp)
|
|
sink._park()
|
|
sink._audio.setVolume.reset_mock()
|
|
sink.set_volume(1.0)
|
|
assert sink._audio.setVolume.call_args[0][0] == 0.0
|
|
|
|
def test_park_remembers_where_we_were(self, qapp):
|
|
sink = _loaded(qapp, position=6974, duration=180000)
|
|
sink._park()
|
|
sink._media.position.return_value = 0 # the torn-down pipeline
|
|
sink._media.duration.return_value = 0
|
|
assert sink.position_ms() == 6974
|
|
assert sink.duration_ms() == 180000
|
|
|
|
def test_park_is_idempotent_and_skips_an_empty_source(self, qapp):
|
|
sink = _sink(qapp)
|
|
sink._media.source.return_value = QUrl()
|
|
sink._park()
|
|
assert not sink._parked
|
|
sink._media.setSource.assert_not_called()
|
|
|
|
def test_park_never_interrupts_playback(self, qapp):
|
|
"""A timer that somehow fires while playing must do nothing."""
|
|
sink = _loaded(qapp)
|
|
sink._media.playbackState.return_value = (
|
|
QMediaPlayer.PlaybackState.PlayingState)
|
|
sink._park()
|
|
assert not sink._parked
|
|
sink._media.setSource.assert_not_called()
|
|
|
|
def test_teardown_does_not_reach_the_ui(self, qapp):
|
|
"""stop()/setSource() churn must not show up as 0:00 or a state flip."""
|
|
sink = _loaded(qapp)
|
|
seen = []
|
|
sink.position_changed.connect(lambda ms: seen.append(("pos", ms)))
|
|
sink.duration_changed.connect(lambda ms: seen.append(("dur", ms)))
|
|
sink.state_changed.connect(lambda p: seen.append(("state", p)))
|
|
sink._park()
|
|
sink._on_position_changed(0)
|
|
sink._on_duration_changed(0)
|
|
sink._on_state_changed(QMediaPlayer.PlaybackState.StoppedState)
|
|
assert seen == []
|
|
|
|
def test_ended_is_not_emitted_while_parked(self, qapp):
|
|
"""A stray EndOfMedia during teardown must not advance the queue."""
|
|
sink = _loaded(qapp)
|
|
ended = []
|
|
sink.ended.connect(lambda: ended.append(True))
|
|
sink._park()
|
|
sink._on_media_status(QMediaPlayer.MediaStatus.EndOfMedia)
|
|
assert ended == []
|
|
|
|
|
|
class TestUnparking:
|
|
def test_play_rebuilds_and_re_arms_the_seek(self, qapp):
|
|
sink = _loaded(qapp, url="file:///music/a.mp3", position=6974)
|
|
sink._park()
|
|
sink._media.setSource.reset_mock()
|
|
sink.play()
|
|
assert not sink._parked
|
|
restored = sink._media.setSource.call_args[0][0]
|
|
assert restored == QUrl("file:///music/a.mp3")
|
|
assert sink._pending_start_ms == 6974
|
|
sink._media.play.assert_called()
|
|
|
|
def test_resume_restores_the_real_volume(self, qapp):
|
|
sink = _loaded(qapp)
|
|
sink.set_volume(0.8)
|
|
sink._park()
|
|
sink._audio.setVolume.reset_mock()
|
|
sink.play()
|
|
assert sink._audio.setVolume.call_args[0][0] > 0.0
|
|
|
|
def test_seek_while_parked_moves_the_resume_point(self, qapp):
|
|
sink = _loaded(qapp)
|
|
sink._park()
|
|
seen = []
|
|
sink.position_changed.connect(seen.append)
|
|
sink.seek(90000)
|
|
sink._media.setPosition.assert_not_called()
|
|
assert sink.position_ms() == 90000
|
|
assert seen == [90000]
|
|
sink.play()
|
|
assert sink._pending_start_ms == 90000
|
|
|
|
def test_loading_a_new_track_discards_parked_state(self, qapp, tmp_path):
|
|
sink = _loaded(qapp)
|
|
sink._park()
|
|
path = tmp_path / "b.mp3"
|
|
path.write_bytes(b"x")
|
|
sink._media.source.return_value = QUrl()
|
|
sink.load(Track(track_id=2, name="B", location=str(path)),
|
|
autoplay=False, start_ms=0)
|
|
assert not sink._parked
|
|
assert sink._parked_url.isEmpty()
|
|
|
|
|
|
class TestTimerWiring:
|
|
def test_pause_arms_the_timer(self, qapp):
|
|
sink = _loaded(qapp)
|
|
sink.pause()
|
|
assert sink._park_timer.isActive()
|
|
|
|
def test_end_of_queue_stop_also_arms_it(self, qapp):
|
|
"""_advance() calls sink.stop() when a playlist runs out; Qt's stop()
|
|
leaves the source loaded, so that path leaks too."""
|
|
sink = _loaded(qapp)
|
|
sink.stop()
|
|
assert sink._park_timer.isActive()
|
|
|
|
def test_play_disarms_the_timer(self, qapp):
|
|
sink = _loaded(qapp)
|
|
sink.pause()
|
|
sink.play()
|
|
assert not sink._park_timer.isActive()
|
|
|
|
def test_shutdown_disarms_the_timer(self, qapp):
|
|
sink = _loaded(qapp)
|
|
sink.pause()
|
|
sink.shutdown()
|
|
assert not sink._park_timer.isActive()
|
|
|
|
def test_timer_fires_park(self, qapp):
|
|
sink = _loaded(qapp)
|
|
assert sink._park_timer.isSingleShot()
|
|
sink._park_timer.timeout.emit()
|
|
assert sink._parked
|