feat(v2): add v2_collection_shuffle_tracks command
End-to-end DJ-mix command. Loads the collection, resolves its natural
in-order track list, dedupes near-duplicate versions, samples up to
sample_size tracks under the per-album cap, replaces the queue, and
starts playback from index 0. Stamps queue_source_collection and bumps
the play counter (mirroring v2_enqueue_collection's replace mode), but
does not modify the collection's persistent play_mode.
Returns ShuffleTracksResult { requestedCount, actualCount } so the
frontend can show the trimmed-mix toast when the cap or dedup shrinks
the pool.
ThreadRng is not Send, so dedup + sample run inside a sync scope that
ends before the bridge awaits.
cc
This commit is contained in:
2 files changed
+130
No files matched your search
@@ -850,3 +850,132 @@ pub async fn v2_collection_unique_track_count(
|
||||
);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Result of [`v2_collection_shuffle_tracks`]: how many tracks the user asked
|
||||
/// for and how many actually ended up in the queue. The two can differ when
|
||||
/// dedup or the per-album cap shrinks the available pool.
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShuffleTracksResult {
|
||||
pub requested_count: usize,
|
||||
pub actual_count: usize,
|
||||
}
|
||||
|
||||
/// Replace the queue with a randomly sampled mix of `sample_size` tracks drawn
|
||||
/// from the collection's natural track list, after similarity-based dedup and
|
||||
/// with a per-album cap. Starts playback from index 0.
|
||||
///
|
||||
/// Mirrors the side effects of `v2_enqueue_collection` in `replace` mode:
|
||||
/// stamps `queue_source_collection` and bumps the play counter. Does NOT
|
||||
/// modify the collection's persistent `play_mode` — this is a one-off action.
|
||||
#[tauri::command]
|
||||
pub async fn v2_collection_shuffle_tracks(
|
||||
collection_id: String,
|
||||
sample_size: usize,
|
||||
library: State<'_, LibraryState>,
|
||||
state: State<'_, crate::AppState>,
|
||||
bridge: State<'_, CoreBridgeState>,
|
||||
runtime: State<'_, RuntimeManagerState>,
|
||||
) -> Result<ShuffleTracksResult, RuntimeError> {
|
||||
use crate::mixtape::enqueue::{ProdItemResolver, resolve_collection_tracks};
|
||||
use crate::mixtape::shuffle;
|
||||
|
||||
runtime
|
||||
.manager()
|
||||
.check_requirements(CommandRequirement::RequiresUserSession)
|
||||
.await?;
|
||||
|
||||
log::info!(
|
||||
"[V2] collection_shuffle_tracks id={} sample_size={}",
|
||||
collection_id,
|
||||
sample_size
|
||||
);
|
||||
|
||||
if sample_size == 0 {
|
||||
return Err(RuntimeError::Internal(
|
||||
"sample_size must be greater than 0".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// 1. Load the collection.
|
||||
let collection = {
|
||||
let guard = acquire_db!(library);
|
||||
let db = guard.as_ref().ok_or(RuntimeError::UserSessionNotActivated)?;
|
||||
db.with_connection(|conn| {
|
||||
crate::mixtape::repo::get_collection(conn, &collection_id)
|
||||
.map_err(|e| RuntimeError::Internal(e.to_string()))
|
||||
})?
|
||||
.ok_or_else(|| RuntimeError::Internal("collection not found".into()))?
|
||||
};
|
||||
|
||||
// 2. Resolve the natural in-order track list.
|
||||
let client_guard = state.client.read().await;
|
||||
let client = client_guard.clone();
|
||||
drop(client_guard);
|
||||
|
||||
let resolver = ProdItemResolver {
|
||||
client: &client,
|
||||
library: &library,
|
||||
};
|
||||
|
||||
let tracks = resolve_collection_tracks(
|
||||
collection.items.clone(),
|
||||
qbz_models::mixtape::CollectionPlayMode::InOrder,
|
||||
&resolver,
|
||||
)
|
||||
.await;
|
||||
|
||||
if tracks.is_empty() {
|
||||
return Err(RuntimeError::Internal(
|
||||
"collection resolved to 0 playable tracks".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// 3. Dedup + sample. ThreadRng is not Send, so confine it to a sync
|
||||
// scope that ends before any subsequent .await.
|
||||
let sampled = {
|
||||
let mut rng = rand::rng();
|
||||
let unique = shuffle::dedup_by_similarity(tracks, &mut rng);
|
||||
shuffle::hybrid_sample(unique, sample_size, &mut rng)
|
||||
};
|
||||
|
||||
if sampled.is_empty() {
|
||||
return Err(RuntimeError::Internal(
|
||||
"sample produced 0 tracks".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let actual = sampled.len();
|
||||
log::info!(
|
||||
"[V2] collection_shuffle_tracks id={}: requested={} actual={}",
|
||||
collection_id,
|
||||
sample_size,
|
||||
actual
|
||||
);
|
||||
|
||||
// 4. Replace the queue and start playback from index 0.
|
||||
let bridge = bridge.get().await;
|
||||
bridge.set_queue(sampled, Some(0)).await;
|
||||
bridge.play_index(0).await;
|
||||
|
||||
// 5. Stamp queue source so skip-next/prev know the boundary semantics.
|
||||
runtime
|
||||
.manager()
|
||||
.set_queue_source_collection(Some(collection_id.clone()))
|
||||
.await;
|
||||
|
||||
// 6. Bump play stats (best-effort).
|
||||
{
|
||||
let guard = acquire_db!(library);
|
||||
if let Some(db) = guard.as_ref() {
|
||||
let _ = db.with_connection(|conn| {
|
||||
crate::mixtape::repo::touch_play(conn, &collection_id)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ShuffleTracksResult {
|
||||
requested_count: sample_size,
|
||||
actual_count: actual,
|
||||
})
|
||||
}
|
||||
@@ -1887,6 +1887,7 @@ pub fn run() {
|
||||
commands_v2::v2_enqueue_collection,
|
||||
commands_v2::v2_enqueue_collection_item,
|
||||
commands_v2::v2_collection_unique_track_count,
|
||||
commands_v2::v2_collection_shuffle_tracks,
|
||||
commands_v2::v2_skip_to_next_item,
|
||||
commands_v2::v2_skip_to_previous_item,
|
||||
])
|
||||
|
||||
Reference in new issue
Block a user