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:
vicrodh committed 2026-06-01 03:28:15 -06:00
1 parent fea9dbe1d4
commit 151e4cbd2d
8 files changed
+85 -2

No files matched your search

+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+19 -1
View File
@@ -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);
});
});
+38
View File
@@ -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
View File
@@ -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 =