Attempt to recover from 'Demuxing failed' errors.
This commit is contained in:
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|||||||
from typing import Callable, List, Optional
|
from typing import Callable, List, Optional
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
from PySide6.QtCore import QObject, QUrl, Signal
|
from PySide6.QtCore import QObject, QUrl, Signal
|
||||||
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
|
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer
|
||||||
@@ -29,6 +30,7 @@ class PlaybackManager(QObject):
|
|||||||
|
|
||||||
def __init__(self, parent: Optional[QObject] = None):
|
def __init__(self, parent: Optional[QObject] = None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self.logger = logging.getLogger("navipy.playback")
|
||||||
self.player = QMediaPlayer()
|
self.player = QMediaPlayer()
|
||||||
self.audioOutput = QAudioOutput()
|
self.audioOutput = QAudioOutput()
|
||||||
self.player.setAudioOutput(self.audioOutput)
|
self.player.setAudioOutput(self.audioOutput)
|
||||||
@@ -38,6 +40,8 @@ class PlaybackManager(QObject):
|
|||||||
self.streamResolver: Optional[Callable[[Song], str]] = None
|
self.streamResolver: Optional[Callable[[Song], str]] = None
|
||||||
self.shuffleEnabled = False
|
self.shuffleEnabled = False
|
||||||
self.repeatMode = "none" # none, one, all
|
self.repeatMode = "none" # none, one, all
|
||||||
|
self._maxRetriesPerTrack = 2
|
||||||
|
self._retryAttemptsForTrack = 0
|
||||||
|
|
||||||
# Shuffle state: shuffleOrder is a list of queue indices in shuffled order
|
# Shuffle state: shuffleOrder is a list of queue indices in shuffled order
|
||||||
# shufflePosition tracks where we are in that order
|
# shufflePosition tracks where we are in that order
|
||||||
@@ -97,6 +101,7 @@ class PlaybackManager(QObject):
|
|||||||
self.currentIndex = -1
|
self.currentIndex = -1
|
||||||
self._shuffleOrder = []
|
self._shuffleOrder = []
|
||||||
self._shufflePosition = -1
|
self._shufflePosition = -1
|
||||||
|
self._retryAttemptsForTrack = 0
|
||||||
self.player.stop()
|
self.player.stop()
|
||||||
self.queueChanged.emit(self.queue.copy())
|
self.queueChanged.emit(self.queue.copy())
|
||||||
self.playbackStateChanged.emit("stopped")
|
self.playbackStateChanged.emit("stopped")
|
||||||
@@ -110,6 +115,7 @@ class PlaybackManager(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.currentIndex = index
|
self.currentIndex = index
|
||||||
|
self._retryAttemptsForTrack = 0
|
||||||
# Sync shuffle position to match the played index
|
# Sync shuffle position to match the played index
|
||||||
self._syncShufflePosition(index)
|
self._syncShufflePosition(index)
|
||||||
song = self.queue[index]
|
song = self.queue[index]
|
||||||
@@ -328,6 +334,10 @@ class PlaybackManager(QObject):
|
|||||||
description = self.player.errorString() or str(error)
|
description = self.player.errorString() or str(error)
|
||||||
currentTime = time.monotonic()
|
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
|
# Debounce: skip if same error within debounce window
|
||||||
if (description == self._lastErrorMessage and
|
if (description == self._lastErrorMessage and
|
||||||
currentTime - self._lastErrorTime < self._errorDebounceSeconds):
|
currentTime - self._lastErrorTime < self._errorDebounceSeconds):
|
||||||
@@ -339,4 +349,60 @@ class PlaybackManager(QObject):
|
|||||||
# Stop playback to prevent further cascading errors
|
# Stop playback to prevent further cascading errors
|
||||||
self.player.stop()
|
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)
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user