fix(qconnect): unify queue replacement through shared helper

cx
This commit is contained in:
vicrodh committed 2026-03-12 17:36:18 -06:00
1 parent 674fff996b
commit bb110f3654
15 files changed
+255 -35

No files matched your search

@@ -19,6 +19,7 @@
import TrackMenu from '../TrackMenu.svelte';
import BulkActionBar from '../BulkActionBar.svelte';
import QualityBadge from '../QualityBadge.svelte';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import { consumeContextTrackFocus, setPlaybackContext, getPlaybackContext } from '$lib/stores/playbackContextStore';
import { saveScrollPosition, getSavedScrollPosition } from '$lib/stores/navigationStore';
import { togglePlay } from '$lib/stores/playerStore';
@@ -1185,7 +1186,9 @@
console.log(`[Artist] Context created: "${artist.name}" top tracks, ${trackIds.length} tracks, starting at ${index}`);
try {
const queueTracks = buildTopTracksQueue(topTracks);
await invoke('v2_set_queue', { tracks: queueTracks, startIndex: index });
await replacePlaybackQueue(queueTracks, index, {
debugLabel: 'artist-detail:top-tracks'
});
} catch (err) {
console.error('Failed to set queue:', err);
}
@@ -9,6 +9,7 @@
import { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters';
import { getDynamicSuggest } from '$lib/services/dynamicSuggest';
import { getHomeSeeds, getHomeSeedsML } from '$lib/services/recoService';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import { showToast } from '$lib/stores/toastStore';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
@@ -362,7 +363,9 @@
);
try {
await invoke('v2_set_queue', { tracks: queueTracks, startIndex: queueIndex });
await replacePlaybackQueue(queueTracks, queueIndex, {
debugLabel: 'dynamic-suggest'
});
} catch (err) {
console.error('Failed to set queue:', err);
}
+4 -1
View File
@@ -7,6 +7,7 @@
import BulkActionBar from '$lib/components/BulkActionBar.svelte';
import { t } from '$lib/i18n';
import { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import { showToast } from '$lib/stores/toastStore';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
@@ -304,7 +305,9 @@
}
try {
await invoke('v2_set_queue', { tracks: queueTracks, startIndex: queueIndex });
await replacePlaybackQueue(queueTracks, queueIndex, {
debugLabel: 'fav-q'
});
} catch (err) {
console.error('Failed to set queue:', err);
}
@@ -22,6 +22,7 @@
import { syncCache as syncAlbumCache } from '$lib/stores/albumFavoritesStore';
import { syncCache as syncArtistCache } from '$lib/stores/artistFavoritesStore';
import { categorizeAlbum, getQobuzImage, formatQuality } from '$lib/adapters/qobuzAdapters';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import { getUserItem, setUserItem } from '$lib/utils/userStorage';
import GenreFilterButton from '../GenreFilterButton.svelte';
import {
@@ -1179,7 +1180,9 @@
async function setFavoritesQueue(startIndex: number) {
if (filteredTracks.length === 0) return;
const queueTracks = buildFavoritesQueueTracks(filteredTracks);
await invoke('v2_set_queue', { tracks: queueTracks, startIndex });
await replacePlaybackQueue(queueTracks, startIndex, {
debugLabel: 'favorites:tracks'
});
}
async function handleTrackClick(track: FavoriteTrack, index: number) {
@@ -1216,7 +1219,9 @@
// Shuffle the tracks
const shuffled = [...filteredTracks].sort(() => Math.random() - 0.5);
const queueTracks = buildFavoritesQueueTracks(shuffled);
await invoke('v2_set_queue', { tracks: queueTracks, startIndex: 0 });
await replacePlaybackQueue(queueTracks, 0, {
debugLabel: 'favorites:shuffle'
});
await setFavoritesContext(shuffled.map(trk => trk.id), 0);
onTrackPlay(buildDisplayTrack(shuffled[0], 0));
} catch (err) {
+11 -2
View File
@@ -8,6 +8,7 @@
import AlbumCard from '../AlbumCard.svelte';
import TrackRow from '../TrackRow.svelte';
import { formatQuality, getQobuzImageForSize } from '$lib/adapters/qobuzAdapters';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import { playTrack } from '$lib/services/playbackService';
import { playQueueIndex } from '$lib/stores/queueStore';
import { resolveArtistImage } from '$lib/stores/customArtistImageStore';
@@ -629,7 +630,9 @@
album_id: track.album?.id || null,
artist_id: track.artist?.id || spotlightData!.artistId,
}));
await invoke('v2_set_queue', { tracks: queueTracks, startIndex: 0 });
await replacePlaybackQueue(queueTracks, 0, {
debugLabel: 'for-you:spotlight-top-tracks'
});
await startRadioPlayback();
} catch (err) {
console.error('Failed to play spotlight top tracks:', err);
@@ -769,7 +772,13 @@
try {
const queueTracks = buildContinueQueueTracks(continueTracks);
await invoke('v2_set_queue', { tracks: queueTracks, startIndex: trackIndex });
const localTrackIds = continueTracks
.filter((trk) => trk.isLocal)
.map((trk) => trk.id);
await replacePlaybackQueue(queueTracks, trackIndex, {
localTrackIds,
debugLabel: 'for-you:continue-listening'
});
} catch (err) {
console.error('Failed to set queue:', err);
}
+8 -1
View File
@@ -38,6 +38,7 @@
hasActiveFilter as hasGenreFilter
} from '$lib/stores/genreFilterStore';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import {
getCachedArtist,
setCachedArtist,
@@ -666,7 +667,13 @@
if (continueTracks.length > 0) {
try {
const queueTracks = buildContinueQueueTracks(continueTracks);
await invoke('v2_set_queue', { tracks: queueTracks, startIndex: trackIndex });
const localTrackIds = continueTracks
.filter((track) => track.isLocal)
.map((track) => track.id);
await replacePlaybackQueue(queueTracks, trackIndex, {
localTrackIds,
debugLabel: 'home:continue-listening'
});
} catch (err) {
console.error('Failed to set queue:', err);
}
+4 -1
View File
@@ -10,6 +10,7 @@
import QobuzPlaylistCard from '../QobuzPlaylistCard.svelte';
import TrackMenu from '../TrackMenu.svelte';
import QualityBadge from '../QualityBadge.svelte';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { togglePlay } from '$lib/stores/playerStore';
import {
@@ -459,7 +460,9 @@
console.log(`[LabelView] Context set: "${pageData?.name}" top tracks, ${trackIds.length} tracks, index ${index}`);
const queueTracks = buildTopTracksQueue(topTracks);
await invoke('v2_set_queue', { tracks: queueTracks, startIndex: index });
await replacePlaybackQueue(queueTracks, index, {
debugLabel: 'label-view:top-tracks'
});
console.log('[LabelView] Queue set successfully');
}
}
@@ -45,6 +45,7 @@
import {
setPlaybackContext
} from '$lib/stores/playbackContextStore';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
// Backend types matching Rust models
interface LocalTrack {
@@ -2182,8 +2183,10 @@
console.log('[LocalLibrary Queue] Mapped to', queueTracks.length, 'queue tracks');
console.log('[LocalLibrary Queue] Track IDs:', queueTracks.map(track => track.id));
await invoke('v2_set_queue', { tracks: queueTracks, startIndex });
onSetLocalQueue?.(tracks.filter(track => track.source !== 'plex').map(track => track.id));
await replacePlaybackQueue(queueTracks, startIndex, {
localTrackIds: tracks.filter(track => track.source !== 'plex').map(track => track.id),
debugLabel: 'local-library:set-queue'
});
console.log('[LocalLibrary Queue] Queue set successfully');
}
@@ -27,6 +27,7 @@
import { t } from '$lib/i18n';
import { onMount, tick } from 'svelte';
import { getUserItem, setUserItem } from '$lib/utils/userStorage';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
interface PlaylistTrack {
id: number;
@@ -1556,10 +1557,10 @@
const allTracks = displayTracks;
if (allTracks.length === 0) return;
const { queueTracks, localIds } = buildQueueTracks(allTracks);
await invoke('v2_set_queue', { tracks: queueTracks, startIndex });
if (localIds.length > 0) {
onSetLocalQueue?.(localIds);
}
await replacePlaybackQueue(queueTracks, startIndex, {
localTrackIds: localIds,
debugLabel: 'playlist-detail:set-queue'
});
}
async function handleTrackClick(track: DisplayTrack, trackIndex: number) {
+4 -1
View File
@@ -9,6 +9,7 @@
import ViewTransition from '../ViewTransition.svelte';
import TrackMenu from '../TrackMenu.svelte';
import QualityBadge from '../QualityBadge.svelte';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import { getSearchState, setSearchState, subscribeSearchFocus, subscribeSearchQuery, setSearchQuery, type SearchResults, type SearchAllResults, type SearchTab, type SearchFilterType, type Playlist } from '$lib/stores/searchState';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { togglePlay } from '$lib/stores/playerStore';
@@ -731,7 +732,9 @@
if (trackResults && trackResults.items.length > 0) {
try {
const queueTracks = buildSearchQueueTracks(trackResults.items);
await invoke('v2_set_queue', { tracks: queueTracks, startIndex: trackIndex });
await replacePlaybackQueue(queueTracks, trackIndex, {
debugLabel: 'search:results'
});
} catch (err) {
console.error('Failed to set queue:', err);
}
+4 -1
View File
@@ -8,6 +8,7 @@
import { t } from '$lib/i18n';
import { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters';
import { getDynamicSuggest } from '$lib/services/dynamicSuggest';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import { showToast } from '$lib/stores/toastStore';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
@@ -417,7 +418,9 @@
}
try {
await invoke('v2_set_queue', { tracks: queueTracks, startIndex: queueIndex });
await replacePlaybackQueue(queueTracks, queueIndex, {
debugLabel: 'top-q'
});
} catch (err) {
console.error('Failed to set queue:', err);
}
@@ -9,6 +9,7 @@
import { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters';
import { getDynamicSuggest } from '$lib/services/dynamicSuggest';
import { getHomeSeeds, getHomeSeedsML } from '$lib/services/recoService';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import { showToast } from '$lib/stores/toastStore';
import { setPlaybackContext } from '$lib/stores/playbackContextStore';
import { isBlacklisted as isArtistBlacklisted } from '$lib/stores/artistBlacklistStore';
@@ -360,7 +361,9 @@
);
try {
await invoke('v2_set_queue', { tracks: queueTracks, startIndex: queueIndex });
await replacePlaybackQueue(queueTracks, queueIndex, {
debugLabel: 'weekly-q'
});
} catch (err) {
console.error('Failed to set queue:', err);
}
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
assessQconnectQueueSync
} from './queuePlaybackService';
import type { BackendQueueTrack } from '$lib/stores/queueStore';
function buildTrack(id: number, overrides: Partial<BackendQueueTrack> = {}): BackendQueueTrack {
return {
id,
title: `Track ${id}`,
artist: 'Artist',
album: 'Album',
duration_secs: 180,
artwork_url: null,
hires: false,
bit_depth: null,
sample_rate: null,
...overrides
};
}
describe('assessQconnectQueueSync', () => {
it('accepts a pure Qobuz queue', () => {
expect(assessQconnectQueueSync([
buildTrack(101),
buildTrack(102),
buildTrack(103)
])).toEqual({
syncable: true,
reason: 'ok',
trackIds: [101, 102, 103],
blockedTrackIds: []
});
});
it('rejects local tracks so remote queue is not desynced', () => {
expect(assessQconnectQueueSync([
buildTrack(101),
buildTrack(9001, { is_local: true, source: 'local' })
])).toEqual({
syncable: false,
reason: 'queue_contains_non_qobuz_tracks',
trackIds: [],
blockedTrackIds: [9001]
});
});
it('rejects Plex tracks', () => {
expect(assessQconnectQueueSync([
buildTrack(7001, { source: 'plex', is_local: false })
])).toEqual({
syncable: false,
reason: 'queue_contains_non_qobuz_tracks',
trackIds: [],
blockedTrackIds: [7001]
});
});
it('rejects an empty queue', () => {
expect(assessQconnectQueueSync([])).toEqual({
syncable: false,
reason: 'empty_queue',
trackIds: [],
blockedTrackIds: []
});
});
});
+113
View File
@@ -0,0 +1,113 @@
import {
setLocalTrackIds,
setQueue,
type BackendQueueTrack
} from '$lib/stores/queueStore';
import { loadQconnectQueue } from '$lib/services/trackActions';
export type QconnectQueueSyncAssessment = {
syncable: boolean;
reason: 'ok' | 'empty_queue' | 'queue_contains_non_qobuz_tracks';
trackIds: number[];
blockedTrackIds: number[];
};
export type ReplacePlaybackQueueOptions = {
clearLocal?: boolean;
localTrackIds?: number[];
syncQconnect?: boolean;
debugLabel?: string;
};
function isQconnectSyncEligibleTrack(track: BackendQueueTrack): boolean {
if (track.is_local) return false;
const source = (track.source ?? '').toLowerCase();
if (source === 'local' || source === 'plex' || source === 'qobuz_download') {
return false;
}
return Number.isFinite(track.id) && track.id > 0;
}
export function assessQconnectQueueSync(tracks: BackendQueueTrack[]): QconnectQueueSyncAssessment {
if (tracks.length === 0) {
return {
syncable: false,
reason: 'empty_queue',
trackIds: [],
blockedTrackIds: []
};
}
const trackIds: number[] = [];
const blockedTrackIds: number[] = [];
for (const track of tracks) {
if (isQconnectSyncEligibleTrack(track)) {
trackIds.push(track.id);
} else {
blockedTrackIds.push(track.id);
}
}
if (blockedTrackIds.length > 0) {
return {
syncable: false,
reason: 'queue_contains_non_qobuz_tracks',
trackIds: [],
blockedTrackIds
};
}
return {
syncable: true,
reason: 'ok',
trackIds,
blockedTrackIds: []
};
}
export async function syncQconnectQueueFromTracks(
tracks: BackendQueueTrack[],
startIndex: number,
debugLabel: string = 'queue-replace'
): Promise<boolean> {
const assessment = assessQconnectQueueSync(tracks);
if (!assessment.syncable) {
console.log('[QConnect/QueueSync] skipped %s: reason=%s blockedTrackIds=%o', debugLabel, assessment.reason, assessment.blockedTrackIds);
return false;
}
console.log('[QConnect/QueueSync] syncing %s: trackCount=%d startIndex=%d', debugLabel, assessment.trackIds.length, startIndex);
return loadQconnectQueue(assessment.trackIds, startIndex);
}
export async function replacePlaybackQueue(
tracks: BackendQueueTrack[],
startIndex: number,
options: ReplacePlaybackQueueOptions = {}
): Promise<boolean> {
const {
clearLocal = true,
localTrackIds = [],
syncQconnect = true,
debugLabel = 'queue-replace'
} = options;
const success = await setQueue(tracks, startIndex, clearLocal);
if (!success) {
return false;
}
if (localTrackIds.length > 0) {
setLocalTrackIds(localTrackIds);
}
if (syncQconnect) {
await syncQconnectQueueFromTracks(tracks, startIndex, debugLabel);
}
return true;
}
+10 -17
View File
@@ -301,6 +301,7 @@
shareSonglinkTrack,
loadQconnectQueue
} from '$lib/services/trackActions';
import { replacePlaybackQueue } from '$lib/services/queuePlaybackService';
import type {
QconnectDiagnosticsPayload,
QconnectQueueSnapshot,
@@ -1559,14 +1560,9 @@
artist_id: trk.artistId ?? album.artistId
}));
await setQueue(queueTracks, 0, true);
// Sync the full queue to QConnect remote so controllers see the album
const trackIds = album.tracks.map(trk => trk.id);
loadQconnectQueue(trackIds, 0).then(ok => {
if (ok) console.log('[QConnect] Album queue synced to remote (%d tracks)', trackIds.length);
else console.warn('[QConnect] Album queue NOT synced to remote (not connected or rejected)');
}).catch(err => console.error('[QConnect] Album queue sync error:', err));
await replacePlaybackQueue(queueTracks, 0, {
debugLabel: 'page:play-album'
});
const firstTrack = album.tracks[0];
const quality = firstTrack.hires && firstTrack.bitDepth && firstTrack.samplingRate
@@ -1784,14 +1780,9 @@
artist_id: trk.performer?.id
}));
await setQueue(queueTracks, 0);
// Sync the full queue to QConnect remote so controllers see the playlist
const trackIds = tracks.map(trk => trk.id);
loadQconnectQueue(trackIds, 0).then(ok => {
if (ok) console.log('[QConnect] Playlist queue synced to remote (%d tracks)', trackIds.length);
else console.warn('[QConnect] Playlist queue NOT synced to remote (not connected or rejected)');
}).catch(err => console.error('[QConnect] Playlist queue sync error:', err));
await replacePlaybackQueue(queueTracks, 0, {
debugLabel: 'page:play-playlist'
});
const firstTrack = tracks[0];
const artwork = firstTrack.album?.image?.large || firstTrack.album?.image?.thumbnail || firstTrack.album?.image?.small || '';
@@ -2013,7 +2004,9 @@
console.log('[Album Queue] Track IDs:', queueTracks.map(trk => trk.id));
// Set the queue starting at the clicked track
await setQueue(queueTracks, trackIndex >= 0 ? trackIndex : 0, true);
await replacePlaybackQueue(queueTracks, trackIndex >= 0 ? trackIndex : 0, {
debugLabel: 'page:album-track-play'
});
console.log('[Album Queue] Queue set successfully');
}