refactor(qconnect): extract runtime helpers from page\n\ncx
This commit is contained in:
5 files changed
+488
-176
No files matched your search
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
evaluateQconnectPlaybackReportSkip,
|
||||
evaluateQconnectSessionPersistence,
|
||||
isQconnectRemoteModeActive,
|
||||
type QconnectConnectionStatus
|
||||
} from './qconnectRuntime';
|
||||
import type { QconnectQueueSnapshot, QconnectRendererSnapshot } from './qconnectRemoteQueue';
|
||||
|
||||
function buildQueueSnapshot(trackIds: number[]): QconnectQueueSnapshot {
|
||||
return {
|
||||
version: { major: 2, minor: 1 },
|
||||
queue_items: trackIds.map((trackId, index) => ({
|
||||
track_id: trackId,
|
||||
queue_item_id: index + 1,
|
||||
track_context_uuid: `ctx-${index + 1}`
|
||||
})),
|
||||
shuffle_mode: false,
|
||||
autoplay_mode: false,
|
||||
autoplay_items: []
|
||||
};
|
||||
}
|
||||
|
||||
describe('evaluateQconnectPlaybackReportSkip', () => {
|
||||
it('does not skip when the current track exists in the remote queue', () => {
|
||||
const result = evaluateQconnectPlaybackReportSkip({
|
||||
currentTrackId: 23943863,
|
||||
queueSnapshot: buildQueueSnapshot([193849747, 23943863, 218534]),
|
||||
rendererSnapshot: null,
|
||||
lastSkipSignature: ''
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
shouldSkip: false,
|
||||
nextSkipSignature: '',
|
||||
diagnosticPayload: null
|
||||
});
|
||||
});
|
||||
|
||||
it('skips and emits a diagnostic when the local track is outside the remote queue and renderer has no current track', () => {
|
||||
const result = evaluateQconnectPlaybackReportSkip({
|
||||
currentTrackId: 46848340,
|
||||
queueSnapshot: buildQueueSnapshot([193849747, 23943863, 218534]),
|
||||
rendererSnapshot: {
|
||||
current_track: null,
|
||||
next_track: { track_id: 23943863, queue_item_id: 2 }
|
||||
},
|
||||
lastSkipSignature: ''
|
||||
});
|
||||
|
||||
expect(result.shouldSkip).toBe(true);
|
||||
expect(result.nextSkipSignature).toBe('46848340:2.1');
|
||||
expect(result.diagnosticPayload).toEqual({
|
||||
reason: 'local_track_not_in_remote_queue',
|
||||
current_track_id: 46848340,
|
||||
remote_queue_preview: buildQueueSnapshot([193849747, 23943863, 218534]).queue_items,
|
||||
renderer_current: null,
|
||||
renderer_next: { track_id: 23943863, queue_item_id: 2 }
|
||||
});
|
||||
});
|
||||
|
||||
it('suppresses duplicate diagnostics for the same skip signature', () => {
|
||||
const queueSnapshot = buildQueueSnapshot([193849747, 23943863, 218534]);
|
||||
const rendererSnapshot: QconnectRendererSnapshot = {
|
||||
current_track: null,
|
||||
next_track: { track_id: 23943863, queue_item_id: 2 }
|
||||
};
|
||||
|
||||
const result = evaluateQconnectPlaybackReportSkip({
|
||||
currentTrackId: 46848340,
|
||||
queueSnapshot,
|
||||
rendererSnapshot,
|
||||
lastSkipSignature: '46848340:2.1'
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
shouldSkip: true,
|
||||
nextSkipSignature: '46848340:2.1',
|
||||
diagnosticPayload: null
|
||||
});
|
||||
});
|
||||
|
||||
it('does not skip when renderer already has a current remote track', () => {
|
||||
const result = evaluateQconnectPlaybackReportSkip({
|
||||
currentTrackId: 46848340,
|
||||
queueSnapshot: buildQueueSnapshot([193849747, 23943863, 218534]),
|
||||
rendererSnapshot: {
|
||||
current_track: { track_id: 193849747, queue_item_id: 1 },
|
||||
next_track: { track_id: 23943863, queue_item_id: 2 }
|
||||
},
|
||||
lastSkipSignature: ''
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
shouldSkip: false,
|
||||
nextSkipSignature: '',
|
||||
diagnosticPayload: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('QConnect runtime state helpers', () => {
|
||||
it('treats transport connectivity as remote mode even if the local flag is stale', () => {
|
||||
const status: QconnectConnectionStatus = {
|
||||
running: true,
|
||||
transport_connected: true,
|
||||
endpoint_url: 'ws://127.0.0.1:12345',
|
||||
last_error: null
|
||||
};
|
||||
|
||||
expect(isQconnectRemoteModeActive(false, status)).toBe(true);
|
||||
});
|
||||
|
||||
it('resets the session persistence skip flag once remote mode ends', () => {
|
||||
expect(evaluateQconnectSessionPersistence(false, true)).toEqual({
|
||||
shouldPersist: true,
|
||||
nextSkipLogged: false,
|
||||
shouldLogSkip: false
|
||||
});
|
||||
});
|
||||
|
||||
it('logs the skip only once while remote mode stays active', () => {
|
||||
expect(evaluateQconnectSessionPersistence(true, false)).toEqual({
|
||||
shouldPersist: false,
|
||||
nextSkipLogged: true,
|
||||
shouldLogSkip: true
|
||||
});
|
||||
|
||||
expect(evaluateQconnectSessionPersistence(true, true)).toEqual({
|
||||
shouldPersist: false,
|
||||
nextSkipLogged: true,
|
||||
shouldLogSkip: false
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type {
|
||||
QconnectQueueSnapshot,
|
||||
QconnectRendererSnapshot
|
||||
} from '$lib/services/qconnectRemoteQueue';
|
||||
|
||||
export type QconnectConnectionStatus = {
|
||||
running: boolean;
|
||||
transport_connected: boolean;
|
||||
endpoint_url?: string | null;
|
||||
last_error?: string | null;
|
||||
};
|
||||
|
||||
export type QconnectRendererInfo = {
|
||||
renderer_id: number;
|
||||
friendly_name?: string | null;
|
||||
brand?: string | null;
|
||||
model?: string | null;
|
||||
device_type?: number | null;
|
||||
};
|
||||
|
||||
export type QconnectSessionSnapshot = {
|
||||
session_uuid?: string | null;
|
||||
active_renderer_id?: number | null;
|
||||
renderers: QconnectRendererInfo[];
|
||||
};
|
||||
|
||||
export type QconnectAdmissionBlockedEvent = {
|
||||
command_type: string;
|
||||
origin: string;
|
||||
reason: string;
|
||||
handoff_intent: 'continue_locally' | 'send_to_connect';
|
||||
};
|
||||
|
||||
export type QconnectDiagnosticsEntry = {
|
||||
ts: number;
|
||||
level: 'info' | 'warn' | 'error';
|
||||
channel: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type QconnectPlaybackReportSkipResult = {
|
||||
shouldSkip: boolean;
|
||||
nextSkipSignature: string;
|
||||
diagnosticPayload: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type QconnectSessionPersistenceDecision = {
|
||||
shouldPersist: boolean;
|
||||
nextSkipLogged: boolean;
|
||||
shouldLogSkip: boolean;
|
||||
};
|
||||
|
||||
export type QconnectRuntimeStateSnapshot = {
|
||||
status: QconnectConnectionStatus;
|
||||
connected: boolean;
|
||||
queueSnapshot: QconnectQueueSnapshot | null;
|
||||
rendererSnapshot: QconnectRendererSnapshot | null;
|
||||
sessionSnapshot: QconnectSessionSnapshot | null;
|
||||
snapshotError: unknown | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_QCONNECT_CONNECTION_STATUS: QconnectConnectionStatus = {
|
||||
running: false,
|
||||
transport_connected: false,
|
||||
endpoint_url: null,
|
||||
last_error: null
|
||||
};
|
||||
|
||||
export const SHOW_QCONNECT_DEV_DIAGNOSTICS = import.meta.env.DEV;
|
||||
export const QCONNECT_DIAGNOSTIC_LOG_LIMIT = 200;
|
||||
|
||||
export function qconnectAdmissionReasonKey(reason: string): string {
|
||||
if (reason === 'local_library_tracks_never_enter_remote_qconnect_queue') {
|
||||
return 'qconnect.admissionBlockedLocalLibrary';
|
||||
}
|
||||
if (reason === 'plex_tracks_never_enter_remote_qconnect_queue') {
|
||||
return 'qconnect.admissionBlockedPlex';
|
||||
}
|
||||
return 'qconnect.admissionBlockedUnknown';
|
||||
}
|
||||
|
||||
function normalizeQconnectDiagnosticPayload(payload: unknown): string {
|
||||
if (typeof payload === 'string') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(payload);
|
||||
} catch {
|
||||
return String(payload);
|
||||
}
|
||||
}
|
||||
|
||||
export function appendQconnectDiagnosticEntry(
|
||||
logs: QconnectDiagnosticsEntry[],
|
||||
channel: string,
|
||||
level: QconnectDiagnosticsEntry['level'],
|
||||
payload: unknown,
|
||||
logLimit: number = QCONNECT_DIAGNOSTIC_LOG_LIMIT
|
||||
): QconnectDiagnosticsEntry[] {
|
||||
return [
|
||||
{
|
||||
ts: Date.now(),
|
||||
level,
|
||||
channel,
|
||||
message: normalizeQconnectDiagnosticPayload(payload)
|
||||
},
|
||||
...logs
|
||||
].slice(0, logLimit);
|
||||
}
|
||||
|
||||
export function logQconnectPlaybackReport(
|
||||
logs: QconnectDiagnosticsEntry[],
|
||||
source: 'interval' | 'player_transition',
|
||||
payload: Record<string, unknown>
|
||||
): QconnectDiagnosticsEntry[] {
|
||||
return appendQconnectDiagnosticEntry(logs, `qconnect:report_playback_state:${source}`, 'info', payload);
|
||||
}
|
||||
|
||||
export function evaluateQconnectPlaybackReportSkip(params: {
|
||||
currentTrackId: number | null | undefined;
|
||||
queueSnapshot: QconnectQueueSnapshot | null;
|
||||
rendererSnapshot: QconnectRendererSnapshot | null;
|
||||
lastSkipSignature: string;
|
||||
}): QconnectPlaybackReportSkipResult {
|
||||
const {
|
||||
currentTrackId,
|
||||
queueSnapshot,
|
||||
rendererSnapshot,
|
||||
lastSkipSignature
|
||||
} = params;
|
||||
|
||||
if (currentTrackId == null || !queueSnapshot) {
|
||||
return {
|
||||
shouldSkip: false,
|
||||
nextSkipSignature: '',
|
||||
diagnosticPayload: null
|
||||
};
|
||||
}
|
||||
|
||||
const remoteQueueContainsTrack =
|
||||
queueSnapshot.queue_items.some((item) => item.track_id === currentTrackId) ||
|
||||
queueSnapshot.autoplay_items.some((item) => item.track_id === currentTrackId);
|
||||
|
||||
if (remoteQueueContainsTrack || rendererSnapshot?.current_track?.track_id != null) {
|
||||
return {
|
||||
shouldSkip: false,
|
||||
nextSkipSignature: '',
|
||||
diagnosticPayload: null
|
||||
};
|
||||
}
|
||||
|
||||
const skipSignature =
|
||||
`${currentTrackId}:${queueSnapshot.version?.major ?? 0}.${queueSnapshot.version?.minor ?? 0}`;
|
||||
|
||||
return {
|
||||
shouldSkip: true,
|
||||
nextSkipSignature: skipSignature,
|
||||
diagnosticPayload: lastSkipSignature === skipSignature
|
||||
? null
|
||||
: {
|
||||
reason: 'local_track_not_in_remote_queue',
|
||||
current_track_id: currentTrackId,
|
||||
remote_queue_preview: queueSnapshot.queue_items.slice(0, 6),
|
||||
renderer_current: rendererSnapshot?.current_track ?? null,
|
||||
renderer_next: rendererSnapshot?.next_track ?? null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function isQconnectRemoteModeActive(
|
||||
connected: boolean,
|
||||
status: QconnectConnectionStatus
|
||||
): boolean {
|
||||
return Boolean(connected || status.transport_connected);
|
||||
}
|
||||
|
||||
export function evaluateQconnectSessionPersistence(
|
||||
remoteModeActive: boolean,
|
||||
skipLogged: boolean
|
||||
): QconnectSessionPersistenceDecision {
|
||||
if (!remoteModeActive) {
|
||||
return {
|
||||
shouldPersist: true,
|
||||
nextSkipLogged: false,
|
||||
shouldLogSkip: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shouldPersist: false,
|
||||
nextSkipLogged: true,
|
||||
shouldLogSkip: !skipLogged
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchQconnectRuntimeState(): Promise<QconnectRuntimeStateSnapshot> {
|
||||
try {
|
||||
const status = await invoke<QconnectConnectionStatus>('v2_qconnect_status');
|
||||
const connected = Boolean(status.transport_connected);
|
||||
|
||||
if (!connected) {
|
||||
return {
|
||||
status,
|
||||
connected,
|
||||
queueSnapshot: null,
|
||||
rendererSnapshot: null,
|
||||
sessionSnapshot: null,
|
||||
snapshotError: null
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const [queueSnapshot, rendererSnapshot, sessionSnapshot] = await Promise.all([
|
||||
invoke<QconnectQueueSnapshot>('v2_qconnect_queue_snapshot'),
|
||||
invoke<QconnectRendererSnapshot>('v2_qconnect_renderer_snapshot'),
|
||||
invoke<QconnectSessionSnapshot>('v2_qconnect_session_snapshot')
|
||||
]);
|
||||
|
||||
return {
|
||||
status,
|
||||
connected,
|
||||
queueSnapshot,
|
||||
rendererSnapshot,
|
||||
sessionSnapshot,
|
||||
snapshotError: null
|
||||
};
|
||||
} catch (snapshotError) {
|
||||
return {
|
||||
status,
|
||||
connected,
|
||||
queueSnapshot: null,
|
||||
rendererSnapshot: null,
|
||||
sessionSnapshot: null,
|
||||
snapshotError
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
status: DEFAULT_QCONNECT_CONNECTION_STATUS,
|
||||
connected: false,
|
||||
queueSnapshot: null,
|
||||
rendererSnapshot: null,
|
||||
sessionSnapshot: null,
|
||||
snapshotError: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleQconnectConnection(connected: boolean): Promise<void> {
|
||||
if (connected) {
|
||||
await invoke('v2_qconnect_disconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
await invoke('v2_qconnect_connect', { options: null });
|
||||
}
|
||||
@@ -19,6 +19,10 @@ import { openPlaylistModal } from '$lib/stores/uiStore';
|
||||
import { showToast as storeShowToast, type ToastType } from '$lib/stores/toastStore';
|
||||
import { t } from '$lib/i18n';
|
||||
import type { QobuzTrack, Track, PlaylistTrack, LocalLibraryTrack, DisplayTrack } from '$lib/types';
|
||||
import {
|
||||
qconnectAdmissionReasonKey
|
||||
} from '$lib/services/qconnectRuntime';
|
||||
import type { QconnectConnectionStatus } from '$lib/services/qconnectRuntime';
|
||||
import {
|
||||
resolveQconnectPlayNextInsertAfter,
|
||||
type QconnectDiagnosticsPayload,
|
||||
@@ -36,10 +40,6 @@ function translate(key: string): string {
|
||||
return get(t)(key);
|
||||
}
|
||||
|
||||
type QconnectStatus = {
|
||||
transport_connected: boolean;
|
||||
};
|
||||
|
||||
type QconnectTrackOrigin =
|
||||
| 'qobuz_online'
|
||||
| 'qobuz_offline_cache'
|
||||
@@ -60,16 +60,6 @@ type QueueTrackActionOptions = {
|
||||
|
||||
const QCONNECT_DIAGNOSTIC_EVENT = 'qconnect:diagnostic';
|
||||
|
||||
function qconnectAdmissionReasonKey(reason: string): string {
|
||||
if (reason === 'local_library_tracks_never_enter_remote_qconnect_queue') {
|
||||
return 'qconnect.admissionBlockedLocalLibrary';
|
||||
}
|
||||
if (reason === 'plex_tracks_never_enter_remote_qconnect_queue') {
|
||||
return 'qconnect.admissionBlockedPlex';
|
||||
}
|
||||
return 'qconnect.admissionBlockedUnknown';
|
||||
}
|
||||
|
||||
function resolveQconnectTrackOrigin(queueTrack: BackendQueueTrack, isLocal: boolean): QconnectTrackOrigin {
|
||||
const source = (queueTrack.source ?? '').toLowerCase();
|
||||
if (source === 'plex') return 'plex';
|
||||
@@ -81,7 +71,7 @@ function resolveQconnectTrackOrigin(queueTrack: BackendQueueTrack, isLocal: bool
|
||||
|
||||
async function isQconnectConnected(): Promise<boolean> {
|
||||
try {
|
||||
const status = await invoke<QconnectStatus>('v2_qconnect_status');
|
||||
const status = await invoke<QconnectConnectionStatus>('v2_qconnect_status');
|
||||
return Boolean(status.transport_connected);
|
||||
} catch (err) {
|
||||
console.warn('[QConnect] isQconnectConnected failed:', err);
|
||||
|
||||
Reference in new issue
Block a user