feat(qconnect): auto-skip remote track on PlaybackError
Consumer maps PlaybackError -> handleSkipForward for streamable/network/ not-found failures on the current queue item; toast otherwise. Predicate shouldAutoSkipOnPlaybackError unit-tested. i18n keys added in 5 locales. cc
This commit is contained in:
8 files changed
+85
-2
No files matched your search
@@ -1811,6 +1811,8 @@
|
||||
"lastErrorLabel": "Letzter Fehler",
|
||||
"queueStateTitle": "Remote-Warteschlange",
|
||||
"mixedQueueNotCast": "Gemischte Warteschlange nicht an Qobuz Connect gesendet (enthält lokale oder Plex-Titel).",
|
||||
"trackUnavailableSkipping": "Titel auf dem Renderer nicht verfügbar, springe zum nächsten.",
|
||||
"trackPlaybackError": "Der Renderer konnte diesen Titel nicht abspielen.",
|
||||
"queueVersionLabel": "Version",
|
||||
"queueItemsLabel": "Warteschlangeneinträge",
|
||||
"autoplayModeLabel": "Autoplay",
|
||||
|
||||
@@ -1811,6 +1811,8 @@
|
||||
"lastErrorLabel": "Last error",
|
||||
"queueStateTitle": "Remote Queue",
|
||||
"mixedQueueNotCast": "Mixed queue not cast to Qobuz Connect (contains local or Plex tracks).",
|
||||
"trackUnavailableSkipping": "Track unavailable on the renderer, skipping to the next one.",
|
||||
"trackPlaybackError": "The renderer could not play this track.",
|
||||
"queueVersionLabel": "Version",
|
||||
"queueItemsLabel": "Queue items",
|
||||
"autoplayModeLabel": "Autoplay",
|
||||
|
||||
@@ -1811,6 +1811,8 @@
|
||||
"lastErrorLabel": "Ultimo error",
|
||||
"queueStateTitle": "Cola remota",
|
||||
"mixedQueueNotCast": "La cola mixta no se envió a Qobuz Connect (tiene pistas locales o de Plex).",
|
||||
"trackUnavailableSkipping": "La pista no está disponible en el reproductor, saltando a la siguiente.",
|
||||
"trackPlaybackError": "El reproductor no pudo reproducir esta pista.",
|
||||
"queueVersionLabel": "Version",
|
||||
"queueItemsLabel": "Items en cola",
|
||||
"autoplayModeLabel": "Autoplay",
|
||||
|
||||
@@ -1811,6 +1811,8 @@
|
||||
"lastErrorLabel": "Dernière erreur",
|
||||
"queueStateTitle": "File d'attente distante",
|
||||
"mixedQueueNotCast": "File d'attente mixte non envoyée à Qobuz Connect (contient des pistes locales ou Plex).",
|
||||
"trackUnavailableSkipping": "Piste indisponible sur le lecteur, passage à la suivante.",
|
||||
"trackPlaybackError": "Le lecteur n'a pas pu lire cette piste.",
|
||||
"queueVersionLabel": "Version",
|
||||
"queueItemsLabel": "Éléments de la file",
|
||||
"autoplayModeLabel": "Lecture auto",
|
||||
|
||||
@@ -1811,6 +1811,8 @@
|
||||
"lastErrorLabel": "Último erro",
|
||||
"queueStateTitle": "Fila remota",
|
||||
"mixedQueueNotCast": "Fila mista não enviada ao Qobuz Connect (contém faixas locais ou do Plex).",
|
||||
"trackUnavailableSkipping": "Faixa indisponível no reprodutor, pulando para a próxima.",
|
||||
"trackPlaybackError": "O reprodutor não conseguiu reproduzir esta faixa.",
|
||||
"queueVersionLabel": "Versão",
|
||||
"queueItemsLabel": "Itens da fila",
|
||||
"autoplayModeLabel": "Autoplay",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
assessQconnectQueueSync
|
||||
assessQconnectQueueSync,
|
||||
shouldAutoSkipOnPlaybackError,
|
||||
type QconnectPlaybackErrorPayload
|
||||
} from './queuePlaybackService';
|
||||
import type { BackendQueueTrack } from '$lib/stores/queueStore';
|
||||
|
||||
@@ -92,3 +94,19 @@ describe('assessQconnectQueueSync — offline-cache', () => {
|
||||
expect(r.blockedTrackIds).toContain(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldAutoSkipOnPlaybackError', () => {
|
||||
it('auto-skips on TrackNotStreamable for the current queue item', () => {
|
||||
const payload: QconnectPlaybackErrorPayload = { queue_item_id: 7, error_type: 'TrackNotStreamable' };
|
||||
expect(shouldAutoSkipOnPlaybackError(payload, 7)).toBe(true);
|
||||
});
|
||||
it('does NOT skip when the failed item is not the one currently playing', () => {
|
||||
expect(shouldAutoSkipOnPlaybackError({ queue_item_id: 9, error_type: 'NetworkError' }, 7)).toBe(false);
|
||||
});
|
||||
it('does NOT skip on Unknown error type (avoid skip storms)', () => {
|
||||
expect(shouldAutoSkipOnPlaybackError({ queue_item_id: 7, error_type: 'Unknown' }, 7)).toBe(false);
|
||||
});
|
||||
it('does NOT skip when current queue item id is null', () => {
|
||||
expect(shouldAutoSkipOnPlaybackError({ queue_item_id: 7, error_type: 'TrackNotStreamable' }, null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,44 @@ export type ReplacePlaybackQueueOptions = {
|
||||
debugLabel?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload shape of a `PlaybackError` qconnect:event (mirrors the Rust
|
||||
* QconnectAppEvent::PlaybackError; error_type serializes as its variant name).
|
||||
*/
|
||||
export type QconnectPlaybackErrorPayload = {
|
||||
queue_item_id: number;
|
||||
error_type:
|
||||
| 'Unknown'
|
||||
| 'TrackNotFound'
|
||||
| 'TrackNotStreamable'
|
||||
| 'TrackMusicDataInvalid'
|
||||
| 'ServiceError'
|
||||
| 'NetworkError'
|
||||
| 'OtherErrors';
|
||||
};
|
||||
|
||||
const AUTO_SKIP_ERROR_TYPES = new Set([
|
||||
'TrackNotFound',
|
||||
'TrackNotStreamable',
|
||||
'TrackMusicDataInvalid',
|
||||
'NetworkError'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Decide whether a renderer PlaybackError should trigger an auto-skip. Only
|
||||
* skips when the failed item IS the one currently playing and the error type is
|
||||
* a deterministic per-track failure — Unknown/ServiceError are excluded to avoid
|
||||
* skip storms on transient/ambiguous errors.
|
||||
*/
|
||||
export function shouldAutoSkipOnPlaybackError(
|
||||
payload: QconnectPlaybackErrorPayload,
|
||||
currentQueueItemId: number | null
|
||||
): boolean {
|
||||
if (currentQueueItemId == null) return false;
|
||||
if (payload.queue_item_id !== currentQueueItemId) return false;
|
||||
return AUTO_SKIP_ERROR_TYPES.has(payload.error_type);
|
||||
}
|
||||
|
||||
export function isQconnectSyncEligibleTrack(track: BackendQueueTrack): boolean {
|
||||
if (track.is_local) return false;
|
||||
|
||||
|
||||
+18
-1
@@ -362,7 +362,9 @@
|
||||
import {
|
||||
replacePlaybackQueue,
|
||||
assessQconnectQueueSync,
|
||||
syncQconnectQueueFromTracks
|
||||
syncQconnectQueueFromTracks,
|
||||
shouldAutoSkipOnPlaybackError,
|
||||
type QconnectPlaybackErrorPayload
|
||||
} from '$lib/services/queuePlaybackService';
|
||||
import type {
|
||||
QconnectDiagnosticsPayload,
|
||||
@@ -5774,6 +5776,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Renderer reported a per-track playback failure. Auto-skip the
|
||||
// failed track if it is the one currently playing and the error is a
|
||||
// deterministic per-track failure; otherwise surface a toast.
|
||||
if ('PlaybackError' in payloadObj) {
|
||||
const pe = payloadObj.PlaybackError as QconnectPlaybackErrorPayload;
|
||||
const currentQid =
|
||||
qobuzConnectRendererSnapshot?.current_track?.queue_item_id ?? null;
|
||||
if (shouldAutoSkipOnPlaybackError(pe, currentQid)) {
|
||||
showToast($t('qconnect.trackUnavailableSkipping'), 'warning');
|
||||
void handleSkipForward();
|
||||
} else {
|
||||
showToast($t('qconnect.trackPlaybackError'), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Sync QBZ local queue when QConnect remote queue changes or
|
||||
// renderer commands move the current track (next/prev from controllers).
|
||||
const needsQueueSync =
|
||||
|
||||
Reference in new issue
Block a user