fix(local-library): use high-positive ids for ephemeral tracks (queue/context u64 compat)
The original design used negative i64 ids to distinguish ephemeral
tracks from DB rows, but the queue and playback-context commands
serialize ids as Vec<u64> end-to-end (V2QueueTrack, v2_set_playback_context).
serde rejects negative numbers at the boundary, so setQueueForLocalTracks
and setPlaybackContext failed the moment the user clicked an ephemeral
track:
invalid value: integer `-1`, expected u64
Switch synthetic ids to a high-positive range (EPHEMERAL_ID_FLOOR =
1<<48) instead. Pros over the negative scheme:
* Valid u64 — passes through the queue/context commands untouched
* Below 2^53 so JS Number round-trips without precision loss
* Far above any realistic DB autoincrement id, so the discrimination
test (`track_id >= EPHEMERAL_ID_FLOOR`) stays unambiguous
Also fixes the snake_case mismatch on EphemeralFolderState — the Rust
struct serializes with its native field names (folder_path,
skipped_files), but the TypeScript type was declared in camelCase, so
ephemeralFolder.folderPath was undefined at render time.
cc
This commit is contained in:
3 files changed
+42
-22
No files matched your search
@@ -839,12 +839,15 @@ pub async fn v2_library_play_track(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Negative ids belong to the ephemeral library (an ad-hoc folder the
|
// Track ids at or above EPHEMERAL_ID_FLOOR belong to the ephemeral
|
||||||
// user opened without persisting it to local_tracks). Resolve to the
|
// library (an ad-hoc folder the user opened without persisting it
|
||||||
// in-memory cache and skip every DB-bound branch below — ephemeral
|
// to local_tracks). Resolve to the in-memory cache and skip every
|
||||||
// tracks are never qobuz_download, never offline-cached, never have a
|
// DB-bound branch below — ephemeral tracks are never qobuz_download,
|
||||||
// CUE pointer. Just read the file and hand it to the player.
|
// never offline-cached, never have a CUE pointer. Just read the
|
||||||
if track_id < 0 {
|
// file and hand it to the player. The high-positive id range keeps
|
||||||
|
// the existing u64-typed queue/context commands happy while still
|
||||||
|
// being unambiguous (DB autoincrement ids never reach 2^48).
|
||||||
|
if track_id >= crate::ephemeral_library::EPHEMERAL_ID_FLOOR {
|
||||||
let track = ephemeral_state
|
let track = ephemeral_state
|
||||||
.get_track(track_id)
|
.get_track(track_id)
|
||||||
.ok_or_else(|| format!("Ephemeral track {} not found (session may have been cleared)", track_id))?;
|
.ok_or_else(|| format!("Ephemeral track {} not found (session may have been cleared)", track_id))?;
|
||||||
|
|||||||
@@ -4,12 +4,20 @@
|
|||||||
//! (a downloaded album they haven't decided to keep, an external drive,
|
//! (a downloaded album they haven't decided to keep, an external drive,
|
||||||
//! etc.), browse it, and play tracks from it without anything landing
|
//! etc.), browse it, and play tracks from it without anything landing
|
||||||
//! in `local_tracks`. The ephemeral session lives only in memory: a
|
//! in `local_tracks`. The ephemeral session lives only in memory: a
|
||||||
//! `HashMap<i64, LocalTrack>` keyed by *synthetic negative ids*. Negative
|
//! `HashMap<i64, LocalTrack>` keyed by *synthetic ids in the high
|
||||||
//! ids are how the rest of the playback pipeline distinguishes ephemeral
|
//! range* (>= `EPHEMERAL_ID_FLOOR` = 2^48). Synthetic ids in this range
|
||||||
//! tracks from DB-resolvable ones — DB ids are always positive
|
//! are how the rest of the playback pipeline distinguishes ephemeral
|
||||||
//! (autoincrement), so a negative `track_id` arriving at
|
//! tracks from DB-resolvable ones — local_tracks autoincrement IDs are
|
||||||
//! `v2_library_play_track` is unambiguously an ephemeral track and gets
|
//! orders of magnitude smaller, so any track_id arriving at
|
||||||
//! routed here instead of the DB.
|
//! `v2_library_play_track` at or above the floor is unambiguously
|
||||||
|
//! ephemeral and gets routed here instead of the DB.
|
||||||
|
//!
|
||||||
|
//! The high-positive design (instead of the obvious "use negatives")
|
||||||
|
//! exists because the queue/playback-context commands serialize ids as
|
||||||
|
//! `u64` end-to-end (V2QueueTrack, v2_set_playback_context) and reject
|
||||||
|
//! negative numbers at the serde boundary. Positive ids above the DB
|
||||||
|
//! range and below 2^53 (JS Number safe limit) are valid u64 *and*
|
||||||
|
//! survive the JSON round-trip without precision loss.
|
||||||
//!
|
//!
|
||||||
//! Only one folder is held at a time; opening a new folder replaces the
|
//! Only one folder is held at a time; opening a new folder replaces the
|
||||||
//! previous session. The state vanishes on app exit by virtue of being
|
//! previous session. The state vanishes on app exit by virtue of being
|
||||||
@@ -22,6 +30,13 @@ use std::sync::Mutex;
|
|||||||
use qbz_library::{LibraryError, LibraryScanner, LocalTrack, MetadataExtractor};
|
use qbz_library::{LibraryError, LibraryScanner, LocalTrack, MetadataExtractor};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Floor for synthetic ephemeral track ids. Any id at or above this
|
||||||
|
/// value is an ephemeral track; below it is a DB row id. Set high
|
||||||
|
/// enough to be impossible to collide with autoincrement DB ids in any
|
||||||
|
/// realistic library size, low enough to fit in JS Number's safe
|
||||||
|
/// integer range (2^53 - 1) so the JSON round-trip stays lossless.
|
||||||
|
pub const EPHEMERAL_ID_FLOOR: i64 = 1 << 48;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone)]
|
#[derive(Debug, Serialize, Clone)]
|
||||||
pub struct EphemeralFolderResult {
|
pub struct EphemeralFolderResult {
|
||||||
pub folder_path: String,
|
pub folder_path: String,
|
||||||
@@ -62,14 +77,14 @@ impl EphemeralLibraryInner {
|
|||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
tracks: HashMap::new(),
|
tracks: HashMap::new(),
|
||||||
next_id: -1,
|
next_id: EPHEMERAL_ID_FLOOR,
|
||||||
current_folder_path: None,
|
current_folder_path: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reset(&mut self) {
|
fn reset(&mut self) {
|
||||||
self.tracks.clear();
|
self.tracks.clear();
|
||||||
self.next_id = -1;
|
self.next_id = EPHEMERAL_ID_FLOOR;
|
||||||
self.current_folder_path = None;
|
self.current_folder_path = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,7 +130,7 @@ impl EphemeralLibraryState {
|
|||||||
match MetadataExtractor::extract(audio_file) {
|
match MetadataExtractor::extract(audio_file) {
|
||||||
Ok(mut track) => {
|
Ok(mut track) => {
|
||||||
track.id = inner.next_id;
|
track.id = inner.next_id;
|
||||||
inner.next_id -= 1;
|
inner.next_id += 1;
|
||||||
track.source = Some("ephemeral".to_string());
|
track.source = Some("ephemeral".to_string());
|
||||||
inner.tracks.insert(track.id, track.clone());
|
inner.tracks.insert(track.id, track.clone());
|
||||||
tracks_out.push(track);
|
tracks_out.push(track);
|
||||||
|
|||||||
@@ -600,10 +600,10 @@
|
|||||||
ephemeralFolder = result;
|
ephemeralFolder = result;
|
||||||
if (result.tracks.length === 0) {
|
if (result.tracks.length === 0) {
|
||||||
showToast($t('library.ephemeralFolder.empty'), 'info');
|
showToast($t('library.ephemeralFolder.empty'), 'info');
|
||||||
} else if (result.skippedFiles > 0) {
|
} else if (result.skipped_files > 0) {
|
||||||
showToast(
|
showToast(
|
||||||
$t('library.ephemeralFolder.openedWithSkips', {
|
$t('library.ephemeralFolder.openedWithSkips', {
|
||||||
values: { count: result.tracks.length, skipped: result.skippedFiles }
|
values: { count: result.tracks.length, skipped: result.skipped_files }
|
||||||
}),
|
}),
|
||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
@@ -1009,18 +1009,20 @@
|
|||||||
// tracks here have synthetic negative ids that route to the in-memory
|
// tracks here have synthetic negative ids that route to the in-memory
|
||||||
// EphemeralLibraryState on the backend (see v2_library_play_track). The
|
// EphemeralLibraryState on the backend (see v2_library_play_track). The
|
||||||
// session vanishes on app exit (no DB persistence).
|
// session vanishes on app exit (no DB persistence).
|
||||||
|
// Field names are snake_case to match the Rust struct serde-serializes
|
||||||
|
// (consistent with LocalTrack, which is also snake_case across the FFI).
|
||||||
type EphemeralFolderState = {
|
type EphemeralFolderState = {
|
||||||
folderPath: string;
|
folder_path: string;
|
||||||
tracks: LocalTrack[];
|
tracks: LocalTrack[];
|
||||||
skippedFiles: number;
|
skipped_files: number;
|
||||||
};
|
};
|
||||||
let ephemeralFolder = $state<EphemeralFolderState | null>(null);
|
let ephemeralFolder = $state<EphemeralFolderState | null>(null);
|
||||||
let openingEphemeralFolder = $state(false);
|
let openingEphemeralFolder = $state(false);
|
||||||
|
|
||||||
const ephemeralFolderName = $derived.by(() => {
|
const ephemeralFolderName = $derived.by(() => {
|
||||||
if (!ephemeralFolder) return '';
|
if (!ephemeralFolder) return '';
|
||||||
const segments = ephemeralFolder.folderPath.split('/').filter(Boolean);
|
const segments = ephemeralFolder.folder_path.split('/').filter(Boolean);
|
||||||
return segments.length > 0 ? segments[segments.length - 1] : ephemeralFolder.folderPath;
|
return segments.length > 0 ? segments[segments.length - 1] : ephemeralFolder.folder_path;
|
||||||
});
|
});
|
||||||
// Set of paths the user has expanded in the tree. Each top-level
|
// Set of paths the user has expanded in the tree. Each top-level
|
||||||
// <LocalLibraryFolderTree> shares this set so siblings stay in sync.
|
// <LocalLibraryFolderTree> shares this set so siblings stay in sync.
|
||||||
@@ -5707,7 +5709,7 @@
|
|||||||
{$t('library.ephemeralFolder.subtitle', {
|
{$t('library.ephemeralFolder.subtitle', {
|
||||||
values: {
|
values: {
|
||||||
count: ephemeralFolder.tracks.length,
|
count: ephemeralFolder.tracks.length,
|
||||||
path: ephemeralFolder.folderPath
|
path: ephemeralFolder.folder_path
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user