feat(v2): register purchases-get-ids, purchases-get-by-type, and dynamic-suggest commands

New V2 Tauri commands:
- v2_purchases_get_ids: fetch purchase IDs with optional type filter
- v2_purchases_get_by_type: fetch all albums or tracks with download status
- v2_dynamic_suggest: typed dynamic suggestions from Qobuz API
- v2_dynamic_suggest_raw: raw JSON variant for debugging

Registered all four in the Tauri invoke handler.
This commit is contained in:
vicrodh committed 2026-02-20 00:15:12 -06:00
1 parent a7ffe13469
commit 494ddce28b
2 files changed
+1660 -631

No files matched your search

+1531 -556
View File
File diff suppressed because it is too large. Load diff
+129 -75
View File
@@ -3,12 +3,12 @@
//! This application uses the Qobuz API but is not certified by Qobuz.
// New multi-crate architecture
pub mod tauri_adapter;
pub mod core_bridge;
pub mod commands_v2;
pub mod core_bridge;
pub mod integrations_v2;
pub mod runtime;
pub mod session_lifecycle;
pub mod integrations_v2;
pub mod tauri_adapter;
pub mod api;
pub mod api_cache;
@@ -22,7 +22,6 @@ pub mod commands;
pub mod config;
pub mod credentials;
pub mod discogs;
pub mod offline_cache;
pub mod flatpak;
pub mod lastfm;
pub mod library;
@@ -34,13 +33,14 @@ pub mod migration;
pub mod musicbrainz;
pub mod network;
pub mod offline;
pub mod offline_cache;
pub mod playback_context;
pub mod plex;
pub mod player;
pub mod playlist_import;
pub mod plex;
pub mod queue;
pub mod reco_store;
pub mod radio_engine;
pub mod reco_store;
pub mod session_store;
pub mod share;
pub mod tray;
@@ -81,7 +81,10 @@ impl AppState {
}
pub fn with_device(device_name: Option<String>) -> Self {
Self::with_device_and_settings(device_name, config::audio_settings::AudioSettings::default())
Self::with_device_and_settings(
device_name,
config::audio_settings::AudioSettings::default(),
)
}
pub fn with_device_and_settings(
@@ -92,7 +95,10 @@ impl AppState {
let playback_cache = match PlaybackCache::new(500 * 1024 * 1024) {
Ok(cache) => Some(Arc::new(cache)),
Err(e) => {
log::warn!("Failed to create playback cache: {}. Disk spillover disabled.", e);
log::warn!(
"Failed to create playback cache: {}. Disk spillover disabled.",
e
);
None
}
};
@@ -110,7 +116,12 @@ impl AppState {
Self {
client: Arc::new(RwLock::new(QobuzClient::default())),
player: Player::new(device_name, audio_settings, Some(viz_tap), audio::AudioDiagnostic::new()),
player: Player::new(
device_name,
audio_settings,
Some(viz_tap),
audio::AudioDiagnostic::new(),
),
queue: QueueManager::new(),
context: ContextManager::new(),
media_controls: MediaControlsManager::new(),
@@ -173,9 +184,10 @@ pub fn run() {
let (saved_device, audio_settings) = config::audio_settings::AudioSettingsStore::new()
.ok()
.and_then(|store| {
store.get_settings().ok().map(|settings| {
(settings.output_device.clone(), settings)
})
store
.get_settings()
.ok()
.map(|settings| (settings.output_device.clone(), settings))
})
.unwrap_or_else(|| {
log::info!("No saved audio settings found, using defaults");
@@ -233,8 +245,7 @@ pub fn run() {
let use_system_titlebar = window_settings.use_system_titlebar;
// Initialize casting state (Chromecast, DLNA) — device-level, not per-user
let cast_state = cast::CastState::new()
.expect("Failed to initialize Chromecast state");
let cast_state = cast::CastState::new().expect("Failed to initialize Chromecast state");
let dlna_state = cast::DlnaState::new(cast_state.media_server.clone())
.expect("Failed to initialize DLNA state");
@@ -252,20 +263,25 @@ pub fn run() {
let download_settings_state = config::download_settings::create_empty_download_settings_state();
let offline_state = offline::OfflineState::new_empty();
let playback_prefs_state = config::playback_preferences::PlaybackPreferencesState::new_empty();
let favorites_prefs_state = config::favorites_preferences::FavoritesPreferencesState::new_empty();
let favorites_prefs_state =
config::favorites_preferences::FavoritesPreferencesState::new_empty();
let favorites_cache_state = config::favorites_cache::FavoritesCacheState::new_empty();
let tray_settings_state = config::tray_settings::TraySettingsState::new_empty();
let remote_control_settings_state = config::remote_control_settings::RemoteControlSettingsState::new_empty();
let remote_control_settings_state =
config::remote_control_settings::RemoteControlSettingsState::new_empty();
let allowed_origins_state = config::remote_control_settings::AllowedOriginsState::new_empty();
// LegalSettings is GLOBAL (not per-user) - must be initialized at startup
// so ToS acceptance can be checked BEFORE attempting auto-login
let legal_settings_state = config::legal_settings::create_legal_settings_state()
.unwrap_or_else(|e| {
log::warn!("Failed to initialize legal settings: {}. Using empty state.", e);
log::warn!(
"Failed to initialize legal settings: {}. Using empty state.",
e
);
config::legal_settings::create_empty_legal_settings_state()
});
let updates_state = updates::UpdatesState::new_empty()
.expect("Failed to initialize empty updates state");
let updates_state =
updates::UpdatesState::new_empty().expect("Failed to initialize empty updates state");
let subscription_state = config::create_empty_subscription_state();
let musicbrainz_state = musicbrainz::MusicBrainzSharedState::new_empty();
let artist_vectors_state = artist_vectors::ArtistVectorStoreState::new_empty();
@@ -278,17 +294,26 @@ pub fn run() {
let lastfm_v2_state = integrations_v2::LastFmV2State::new();
let developer_settings_state = config::developer_settings::DeveloperSettingsState::new()
.unwrap_or_else(|e| {
log::warn!("Failed to initialize developer settings: {}. Using empty state.", e);
log::warn!(
"Failed to initialize developer settings: {}. Using empty state.",
e
);
config::developer_settings::DeveloperSettingsState::new_empty()
});
let graphics_settings_state = config::graphics_settings::GraphicsSettingsState::new()
.unwrap_or_else(|e| {
log::warn!("Failed to initialize graphics settings: {}. Using empty state.", e);
log::warn!(
"Failed to initialize graphics settings: {}. Using empty state.",
e
);
config::graphics_settings::GraphicsSettingsState::new_empty()
});
let window_settings_state = config::window_settings::WindowSettingsState::new()
.unwrap_or_else(|e| {
log::warn!("Failed to initialize window settings: {}. Using empty state.", e);
let window_settings_state =
config::window_settings::WindowSettingsState::new().unwrap_or_else(|e| {
log::warn!(
"Failed to initialize window settings: {}. Using empty state.",
e
);
config::window_settings::WindowSettingsState::new_empty()
});
@@ -309,7 +334,10 @@ pub fn run() {
// Check if second instance was launched with a Qobuz link arg
for arg in &args {
if arg.starts_with("qobuzapp://") || arg.contains("play.qobuz.com/") || arg.contains("open.qobuz.com/") {
if arg.starts_with("qobuzapp://")
|| arg.contains("play.qobuz.com/")
|| arg.contains("open.qobuz.com/")
{
if let Ok(resolved) = qbz_qobuz::resolve_link(arg) {
log::info!("Single-instance forwarding link: {:?}", resolved);
let _ = app.emit("link:resolved", &resolved);
@@ -321,7 +349,10 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_clipboard_manager::init())
.manage(AppState::with_device_and_settings(saved_device, audio_settings))
.manage(AppState::with_device_and_settings(
saved_device,
audio_settings,
))
.manage(core_bridge::CoreBridgeState::new())
.manage(runtime::RuntimeManagerState::new())
.manage(user_data_paths)
@@ -332,10 +363,7 @@ pub fn run() {
// decorations:true at runtime (WM buttons don't work). By
// creating the window with the right value from the start, the
// WM sees the correct state when the window is first mapped.
log::info!(
"Creating main window (decorations={})",
use_system_titlebar
);
log::info!("Creating main window (decorations={})", use_system_titlebar);
tauri::WebviewWindowBuilder::new(
app,
"main",
@@ -390,7 +418,9 @@ pub fn run() {
let v2_state = b.player().state.clone();
*v2_player_state_setter.write().await = Some(v2_state);
*core_bridge_arc.write().await = Some(b);
log::info!("CoreBridge initialized successfully (V2 player state captured)");
log::info!(
"CoreBridge initialized successfully (V2 player state captured)"
);
}
Err(e) => {
log::error!("Failed to initialize CoreBridge: {}", e);
@@ -411,8 +441,12 @@ pub fn run() {
{
let launch_handle = app.handle().clone();
let args: Vec<String> = std::env::args().collect();
for arg in &args[1..] { // skip binary name
if arg.starts_with("qobuzapp://") || arg.contains("play.qobuz.com/") || arg.contains("open.qobuz.com/") {
for arg in &args[1..] {
// skip binary name
if arg.starts_with("qobuzapp://")
|| arg.contains("play.qobuz.com/")
|| arg.contains("open.qobuz.com/")
{
if let Ok(resolved) = qbz_qobuz::resolve_link(arg) {
log::info!("Launch arg link resolved: {:?}", resolved);
// Delay emission to give frontend time to mount
@@ -438,43 +472,39 @@ pub fn run() {
loop {
// Check V2 player state first (takes priority if active)
// V2 player is accessed via async lock, but we only need a clone
let v2_state_opt: Option<qbz_player::SharedState> = v2_player_state.read().await.clone();
let v2_state_opt: Option<qbz_player::SharedState> =
v2_player_state.read().await.clone();
// Determine which player is active (V2 takes priority if it has a track)
let (is_playing, track_id, position, duration, volume, sample_rate, bit_depth, normalization_gain, gapless_ready, gapless_next_track_id) =
if let Some(ref v2_state) = v2_state_opt {
let v2_track_id = v2_state.current_track_id();
if v2_track_id != 0 {
// V2 player is active
(
v2_state.is_playing(),
v2_track_id,
v2_state.current_position(),
v2_state.duration(),
v2_state.volume(),
v2_state.get_sample_rate(),
v2_state.get_bit_depth(),
v2_state.get_normalization_gain(),
v2_state.is_gapless_ready(),
v2_state.get_gapless_next_track_id(),
)
} else {
// Fallback to legacy player
(
legacy_player_state.is_playing(),
legacy_player_state.current_track_id(),
legacy_player_state.current_position(),
legacy_player_state.duration(),
legacy_player_state.volume(),
legacy_player_state.get_sample_rate(),
legacy_player_state.get_bit_depth(),
legacy_player_state.get_normalization_gain(),
legacy_player_state.is_gapless_ready(),
legacy_player_state.get_gapless_next_track_id(),
)
}
let (
is_playing,
track_id,
position,
duration,
volume,
sample_rate,
bit_depth,
normalization_gain,
gapless_ready,
gapless_next_track_id,
) = if let Some(ref v2_state) = v2_state_opt {
let v2_track_id = v2_state.current_track_id();
if v2_track_id != 0 {
// V2 player is active
(
v2_state.is_playing(),
v2_track_id,
v2_state.current_position(),
v2_state.duration(),
v2_state.volume(),
v2_state.get_sample_rate(),
v2_state.get_bit_depth(),
v2_state.get_normalization_gain(),
v2_state.is_gapless_ready(),
v2_state.get_gapless_next_track_id(),
)
} else {
// V2 not initialized yet, use legacy
// Fallback to legacy player
(
legacy_player_state.is_playing(),
legacy_player_state.current_track_id(),
@@ -487,7 +517,22 @@ pub fn run() {
legacy_player_state.is_gapless_ready(),
legacy_player_state.get_gapless_next_track_id(),
)
};
}
} else {
// V2 not initialized yet, use legacy
(
legacy_player_state.is_playing(),
legacy_player_state.current_track_id(),
legacy_player_state.current_position(),
legacy_player_state.duration(),
legacy_player_state.volume(),
legacy_player_state.get_sample_rate(),
legacy_player_state.get_bit_depth(),
legacy_player_state.get_normalization_gain(),
legacy_player_state.is_gapless_ready(),
legacy_player_state.get_gapless_next_track_id(),
)
};
// Emit when:
// 1) normal in-track state changes/position updates, or
@@ -495,11 +540,11 @@ pub fn run() {
// Case (2) is required so frontend can run end-of-track fallback
// auto-advance paths reliably.
let track_cleared = track_id == 0 && last_track_id != 0;
let should_emit = (track_id != 0 && (
is_playing != last_is_playing
|| track_id != last_track_id
|| (is_playing && position != last_position)
)) || track_cleared;
let should_emit = (track_id != 0
&& (is_playing != last_is_playing
|| track_id != last_track_id
|| (is_playing && position != last_position)))
|| track_cleared;
let should_update_mpris = should_emit || (track_id == 0 && last_track_id != 0);
@@ -519,7 +564,11 @@ pub fn run() {
duration,
track_id,
volume,
sample_rate: if sample_rate > 0 { Some(sample_rate) } else { None },
sample_rate: if sample_rate > 0 {
Some(sample_rate)
} else {
None
},
bit_depth: if bit_depth > 0 { Some(bit_depth) } else { None },
shuffle: Some(shuffle),
repeat: Some(repeat.to_string()),
@@ -564,7 +613,8 @@ pub fn run() {
// Handle close to tray — read dynamically from state so per-user
// settings take effect after login without needing a restart.
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
let close_to_tray = window.app_handle()
let close_to_tray = window
.app_handle()
.try_state::<config::tray_settings::TraySettingsState>()
.and_then(|state| state.get_settings().ok())
.map(|s| s.close_to_tray)
@@ -1026,6 +1076,8 @@ pub fn run() {
commands_v2::v2_discogs_download_artwork,
commands_v2::v2_check_album_fully_cached,
commands_v2::v2_purchases_get_all,
commands_v2::v2_purchases_get_ids,
commands_v2::v2_purchases_get_by_type,
commands_v2::v2_purchases_search,
commands_v2::v2_purchases_get_album,
commands_v2::v2_purchases_get_formats,
@@ -1034,6 +1086,8 @@ pub fn run() {
commands_v2::v2_purchases_mark_downloaded,
commands_v2::v2_purchases_remove_downloaded,
commands_v2::v2_purchases_get_downloaded_track_ids,
commands_v2::v2_dynamic_suggest,
commands_v2::v2_dynamic_suggest_raw,
commands_v2::v2_resolve_qobuz_link,
commands_v2::v2_get_qobuz_track_url,
commands_v2::v2_check_qobuzapp_handler,