qconnect: on-connect queue push runs mixed-queue guard

The TransportConnected handler pushed the live queue via loadQconnectQueue
with no source check, shipping local/Plex row ids as Qobuz ids. The frontend
display queue (getQueueState) carries no `source`/`is_local`, so wire the push
to getBackendQueueState() whose tracks DO carry them (v2_get_queue_state maps
source per track in commands_v2/queue.rs). Route the ordered snapshot
([history, current, upcoming]) through assessQconnectQueueSync: refuse the
whole push on a mixed queue (no silent subset) and toast the user via the new
qconnect.mixedQueueNotCast key (all five locales). Lock the refuse-not-subset
contract with a unit test; page wiring covered by svelte-check + manual verify.

cc
This commit is contained in:
vicrodh committed 2026-06-01 02:07:30 -06:00
1 parent 434c221866
commit b5990a24e6
7 files changed
+53 -14

No files matched your search

+1
View File
@@ -1810,6 +1810,7 @@
"endpointLabel": "Endpunkt",
"lastErrorLabel": "Letzter Fehler",
"queueStateTitle": "Remote-Warteschlange",
"mixedQueueNotCast": "Gemischte Warteschlange nicht an Qobuz Connect gesendet (enthält lokale oder Plex-Titel).",
"queueVersionLabel": "Version",
"queueItemsLabel": "Warteschlangeneinträge",
"autoplayModeLabel": "Autoplay",
+1
View File
@@ -1810,6 +1810,7 @@
"endpointLabel": "Endpoint",
"lastErrorLabel": "Last error",
"queueStateTitle": "Remote Queue",
"mixedQueueNotCast": "Mixed queue not cast to Qobuz Connect (contains local or Plex tracks).",
"queueVersionLabel": "Version",
"queueItemsLabel": "Queue items",
"autoplayModeLabel": "Autoplay",
+1
View File
@@ -1810,6 +1810,7 @@
"endpointLabel": "Endpoint",
"lastErrorLabel": "Ultimo error",
"queueStateTitle": "Cola remota",
"mixedQueueNotCast": "La cola mixta no se envió a Qobuz Connect (tiene pistas locales o de Plex).",
"queueVersionLabel": "Version",
"queueItemsLabel": "Items en cola",
"autoplayModeLabel": "Autoplay",
+1
View File
@@ -1810,6 +1810,7 @@
"endpointLabel": "Point d'accès",
"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).",
"queueVersionLabel": "Version",
"queueItemsLabel": "Éléments de la file",
"autoplayModeLabel": "Lecture auto",
+1
View File
@@ -1810,6 +1810,7 @@
"endpointLabel": "Endpoint",
"lastErrorLabel": "Último erro",
"queueStateTitle": "Fila remota",
"mixedQueueNotCast": "Fila mista não enviada ao Qobuz Connect (contém faixas locais ou do Plex).",
"queueVersionLabel": "Versão",
"queueItemsLabel": "Itens da fila",
"autoplayModeLabel": "Autoplay",
@@ -65,6 +65,16 @@ describe('assessQconnectQueueSync', () => {
blockedTrackIds: []
});
});
it('refuses (not subset) when queue is mixed — on-connect contract', () => {
const r = assessQconnectQueueSync([
buildTrack(1, { source: 'qobuz' }),
buildTrack(2, { source: 'local' })
]);
expect(r.syncable).toBe(false);
expect(r.reason).toBe('queue_contains_non_qobuz_tracks');
expect(r.trackIds).toEqual([]); // subset NOT returned
});
});
describe('assessQconnectQueueSync — offline-cache', () => {
+38 -14
View File
@@ -357,10 +357,13 @@
handleAddToFavorites,
addToPlaylist,
shareQobuzTrackLink,
shareSonglinkTrack,
loadQconnectQueue
shareSonglinkTrack
} from '$lib/services/trackActions';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import {
replacePlaybackQueue,
assessQconnectQueueSync,
syncQconnectQueueFromTracks
} from '$lib/services/queuePlaybackService';
import type {
QconnectDiagnosticsPayload,
QconnectQueueSnapshot,
@@ -5726,17 +5729,38 @@
// Delay briefly so the QConnect session setup (ask_for_queue_state etc.)
// completes before we try to push our queue.
setTimeout(() => {
const queueState = getQueueState();
const trackIds = queueState.queue
.map(item => item.trackId ?? parseInt(item.id))
.filter((id): id is number => typeof id === 'number' && !isNaN(id) && id > 0);
if (trackIds.length > 0) {
console.log('[QConnect] Pushing local queue to remote on connect (%d tracks)', trackIds.length);
loadQconnectQueue(trackIds, 0).then(ok => {
if (ok) console.log('[QConnect] Local queue pushed to remote on connect');
else console.warn('[QConnect] Local queue NOT pushed on connect (rejected or failed)');
}).catch(err => console.error('[QConnect] Local queue push on connect error:', err));
}
void (async () => {
// Use the BACKEND queue snapshot: its tracks carry `source`
// and `is_local`, which the frontend display queue
// (getQueueState) does not. The mixed-queue guard needs them
// to refuse pushing a local/Plex row id to a Qobuz renderer.
const state = await getBackendQueueState();
if (!state) {
console.warn('[QConnect] On-connect push skipped: no backend queue snapshot');
return;
}
const tracks = [
...state.history,
...(state.current_track ? [state.current_track] : []),
...state.upcoming
];
// Start index = the current track's position in the ordered list.
const startIndex = state.current_track ? state.history.length : 0;
const assessment = assessQconnectQueueSync(tracks);
if (!assessment.syncable) {
console.warn('[QConnect] On-connect push refused: reason=%s blocked=%o',
assessment.reason, assessment.blockedTrackIds);
if (assessment.reason === 'queue_contains_non_qobuz_tracks') {
showToast($t('qconnect.mixedQueueNotCast'), 'warning');
}
return;
}
console.log('[QConnect] Pushing local queue to remote on connect (%d tracks)', tracks.length);
const ok = await syncQconnectQueueFromTracks(tracks, startIndex, 'on-connect-push');
console.log(ok
? '[QConnect] Local queue pushed to remote on connect'
: '[QConnect] Local queue NOT pushed on connect (rejected or failed)');
})().catch(err => console.error('[QConnect] Local queue push on connect error:', err));
}, 2000);
}
}