qconnect: allow offline-cache to be cast (one consistent rule)

Three sites disagreed: model blocked OfflineCache, live gate allowed it, TS
sync filter blocked qobuz_download. Decision: ALLOW — an offline copy's queue
id is the real Qobuz track id. Flip the TS sync filter and fix the stale model
doc comment to match the live gate.

Also add Rust lock-in tests (offline_cache_is_castable / offline_cache_is_admitted)
and restore the missing $app/environment vitest mock the config already aliases,
so queuePlaybackService.test.ts can load.

cc
This commit is contained in:
vicrodh committed 2026-06-01 01:52:22 -06:00
1 parent 499b77879d
commit a16d6d0c81
5 files changed
+47 -7

No files matched your search

+11 -6
View File
@@ -9,8 +9,8 @@
//! This is the frontend-agnostic contract behind the source-aware playback
//! context: the now-playing bar, the queue, and the artwork pipeline consume
//! `PlaybackSource` + [`ArtworkRef`] and never special-case a source themselves.
//! The same contract drives the Qobuz Connect queue gate (only Qobuz-streamable
//! tracks may be cast — see [`PlaybackSource::is_qobuz_streamable`]).
//! The same contract drives the Qobuz Connect queue gate (only castable tracks
//! may be cast — see [`PlaybackSource::is_castable_to_qconnect`]).
use serde::{Deserialize, Serialize};
@@ -54,10 +54,9 @@ impl PlaybackSource {
}
}
/// Whether this source streams from Qobuz and may therefore be cast onto
/// a Qobuz Connect queue. Offline-cache copies, local files, and Plex are
/// **not** castable to a Qobuz Connect receiver — this is the first-class
/// gate the queue pipeline consults before syncing to Qobuz Connect.
/// Whether this source streams live from the Qobuz catalog. NOTE: NOT the
/// cast gate — offline-cache also carries a valid Qobuz id and IS castable.
/// Use is_castable_to_qconnect for the Qobuz Connect gate.
pub fn is_qobuz_streamable(self) -> bool {
matches!(self, Self::Qobuz)
}
@@ -193,6 +192,12 @@ mod tests {
}
}
#[test]
fn offline_cache_is_castable() {
assert!(PlaybackSource::OfflineCache.is_castable_to_qconnect());
assert!(TrackOriginTag::OfflineCache.is_castable_to_qconnect());
}
#[test]
fn strict_parse_blocks_unknown_and_absent() {
use TrackOriginTag::*;
+5
View File
@@ -82,4 +82,9 @@ mod tests {
);
}
}
#[test]
fn offline_cache_is_admitted() {
assert!(evaluate_remote_queue_admission(TrackOrigin::QobuzOfflineCache).accepted);
}
}
@@ -66,3 +66,24 @@ describe('assessQconnectQueueSync', () => {
});
});
});
const track = (over: Partial<BackendQueueTrack>): BackendQueueTrack =>
({ id: 1, title: 't', artist: 'a', album: 'al', duration_secs: 0, artwork_url: null,
hires: false, bit_depth: null, sample_rate: null, is_local: false, album_id: null,
artist_id: null, streamable: true, source: 'qobuz', parental_warning: false, ...over }) as BackendQueueTrack;
describe('assessQconnectQueueSync — offline-cache', () => {
it('treats qobuz_download as syncable (offline copy carries a Qobuz id)', () => {
const r = assessQconnectQueueSync([track({ id: 5, source: 'qobuz_download' })]);
expect(r.syncable).toBe(true);
expect(r.trackIds).toEqual([5]);
expect(r.blockedTrackIds).toEqual([]);
});
it('still blocks local and plex', () => {
const r = assessQconnectQueueSync([
track({ id: 1, source: 'qobuz' }), track({ id: 2, source: 'local' }),
]);
expect(r.syncable).toBe(false);
expect(r.blockedTrackIds).toContain(2);
});
});
+2 -1
View File
@@ -23,7 +23,8 @@ function isQconnectSyncEligibleTrack(track: BackendQueueTrack): boolean {
if (track.is_local) return false;
const source = (track.source ?? '').toLowerCase();
if (source === 'local' || source === 'plex' || source === 'qobuz_download') {
// Offline-cache (qobuz_download) IS eligible: its id is the real Qobuz id.
if (source === 'local' || source === 'plex') {
return false;
}
+8
View File
@@ -0,0 +1,8 @@
// Test mock for SvelteKit's `$app/environment`.
// The vitest config aliases `$app` to this directory, but the module was never
// created, so any test whose import graph reaches `$app/environment` (e.g. via
// the i18n module) failed to load. This provides the minimal surface the app uses.
export const browser = false;
export const dev = false;
export const building = false;
export const version = 'test';