diff --git a/src/managers/playback_manager.py b/src/managers/playback_manager.py index ebede61..ba3dad9 100644 --- a/src/managers/playback_manager.py +++ b/src/managers/playback_manager.py @@ -7,6 +7,7 @@ from __future__ import annotations from typing import Callable, List, Optional import random import time +import logging from PySide6.QtCore import QObject, QUrl, Signal from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer @@ -29,6 +30,7 @@ class PlaybackManager(QObject): def __init__(self, parent: Optional[QObject] = None): super().__init__(parent) + self.logger = logging.getLogger("navipy.playback") self.player = QMediaPlayer() self.audioOutput = QAudioOutput() self.player.setAudioOutput(self.audioOutput) @@ -38,6 +40,8 @@ class PlaybackManager(QObject): self.streamResolver: Optional[Callable[[Song], str]] = None self.shuffleEnabled = False self.repeatMode = "none" # none, one, all + self._maxRetriesPerTrack = 2 + self._retryAttemptsForTrack = 0 # Shuffle state: shuffleOrder is a list of queue indices in shuffled order # shufflePosition tracks where we are in that order @@ -97,6 +101,7 @@ class PlaybackManager(QObject): self.currentIndex = -1 self._shuffleOrder = [] self._shufflePosition = -1 + self._retryAttemptsForTrack = 0 self.player.stop() self.queueChanged.emit(self.queue.copy()) self.playbackStateChanged.emit("stopped") @@ -110,6 +115,7 @@ class PlaybackManager(QObject): return self.currentIndex = index + self._retryAttemptsForTrack = 0 # Sync shuffle position to match the played index self._syncShufflePosition(index) song = self.queue[index] @@ -328,6 +334,10 @@ class PlaybackManager(QObject): description = self.player.errorString() or str(error) currentTime = time.monotonic() + # Try to recover from transient stream errors (e.g., "Demuxing failed") + if self._attemptRecovery(description): + return + # Debounce: skip if same error within debounce window if (description == self._lastErrorMessage and currentTime - self._lastErrorTime < self._errorDebounceSeconds): @@ -339,4 +349,60 @@ class PlaybackManager(QObject): # Stop playback to prevent further cascading errors self.player.stop() + if self._retryAttemptsForTrack >= self._maxRetriesPerTrack and len(self.queue) > 1: + self.logger.error( + "Playback error persisted after %d retries: %s; skipping to next track", + self._retryAttemptsForTrack, + description + ) + self.errorOccurred.emit(f"{description}. Skipping to next track.") + self.next(fromAuto=True) + return + self.errorOccurred.emit(description) + + def _shouldRetry(self, description: str) -> bool: + """ + Decide if the current error is transient enough to retry the stream. + We only retry a limited number of times per track and for errors that + look network/demux related rather than permanent format issues. + """ + if self.currentIndex == -1 or not self.streamResolver: + return False + if self._retryAttemptsForTrack >= self._maxRetriesPerTrack: + return False + + text = description.lower() + return "demux" in text or "network" in text or "timeout" in text + + def _attemptRecovery(self, description: str) -> bool: + """Retry the current stream before surfacing the error to the UI.""" + if not self._shouldRetry(description): + return False + + song = self.currentSong() + if not song: + return False + + position = self.player.position() + streamUrl = self.streamResolver(song) + if not streamUrl: + return False + + self._retryAttemptsForTrack += 1 + self.logger.warning( + "Playback error '%s' on '%s' (%s); retrying (%d/%d) at %d ms", + description, + song.title, + song.id, + self._retryAttemptsForTrack, + self._maxRetriesPerTrack, + position + ) + + self.player.stop() + self.player.setSource(QUrl(streamUrl)) + if position > 0: + self.player.setPosition(position) + self.player.play() + return True