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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
|