From 1ad16338fa766ecb16118cae46b40d2dfd126fe8 Mon Sep 17 00:00:00 2001 From: vicrodh Date: Thu, 19 Mar 2026 11:36:56 -0600 Subject: [PATCH] feat: add Qobuz playlist follow/unfollow (#172) Discovered undocumented Qobuz API endpoints from bundle analysis: - POST /playlist/subscribe { playlist_id } - POST /playlist/unsubscribe { playlist_id } This is the native "follow playlist" feature that syncs across all Qobuz clients, separate from QBZ's existing "Copy to Library" (local copy) and "Favorite" (local flag) features. Added: - subscribe_playlist/unsubscribe_playlist in both qbz-qobuz and legacy client - v2_qobuz_subscribe_playlist / v2_qobuz_unsubscribe_playlist commands - Bookmark toggle button in PlaylistDetailView (next to Copy button) - i18n keys in all 5 locales cc --- crates/qbz-qobuz/src/client.rs | 48 +++++++++++++++++++ crates/qbz-qobuz/src/endpoints.rs | 2 + src-tauri/src/api/client.rs | 28 +++++++++++ src-tauri/src/api/endpoints.rs | 2 + src-tauri/src/commands_v2.rs | 30 ++++++++++++ src-tauri/src/lib.rs | 2 + .../views/PlaylistDetailView.svelte | 43 ++++++++++++++++- src/lib/i18n/locales/de.json | 2 + src/lib/i18n/locales/en.json | 2 + src/lib/i18n/locales/es.json | 2 + src/lib/i18n/locales/fr.json | 2 + src/lib/i18n/locales/pt.json | 2 + 12 files changed, 163 insertions(+), 2 deletions(-) diff --git a/crates/qbz-qobuz/src/client.rs b/crates/qbz-qobuz/src/client.rs index 375209960..18bdb3c83 100644 --- a/crates/qbz-qobuz/src/client.rs +++ b/crates/qbz-qobuz/src/client.rs @@ -1622,6 +1622,54 @@ impl QobuzClient { Ok(response) } + /// Subscribe to a Qobuz playlist (follow it in the user's library) + pub async fn subscribe_playlist(&self, playlist_id: u64) -> Result<()> { + let url = endpoints::build_url(paths::PLAYLIST_SUBSCRIBE); + + let response = self + .http + .get(&url) + .headers(self.authenticated_headers().await?) + .query(&[("playlist_id", playlist_id.to_string())]) + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(ApiError::ApiResponse(format!( + "playlist/subscribe failed ({}): {}", + status, body + ))); + } + + Ok(()) + } + + /// Unsubscribe from a Qobuz playlist + pub async fn unsubscribe_playlist(&self, playlist_id: u64) -> Result<()> { + let url = endpoints::build_url(paths::PLAYLIST_UNSUBSCRIBE); + + let response = self + .http + .get(&url) + .headers(self.authenticated_headers().await?) + .query(&[("playlist_id", playlist_id.to_string())]) + .send() + .await?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(ApiError::ApiResponse(format!( + "playlist/unsubscribe failed ({}): {}", + status, body + ))); + } + + Ok(()) + } + /// Add item to favorites pub async fn add_favorite(&self, fav_type: &str, item_id: &str) -> Result<()> { let url = endpoints::build_url(paths::FAVORITE_CREATE); diff --git a/crates/qbz-qobuz/src/endpoints.rs b/crates/qbz-qobuz/src/endpoints.rs index ac292c7ac..03c7fa754 100644 --- a/crates/qbz-qobuz/src/endpoints.rs +++ b/crates/qbz-qobuz/src/endpoints.rs @@ -39,6 +39,8 @@ pub mod paths { pub const PLAYLIST_DELETE_TRACKS: &str = "/playlist/deleteTracks"; pub const PLAYLIST_UPDATE: &str = "/playlist/update"; pub const PLAYLIST_GET_TAGS: &str = "/playlist/getTags"; + pub const PLAYLIST_SUBSCRIBE: &str = "/playlist/subscribe"; + pub const PLAYLIST_UNSUBSCRIBE: &str = "/playlist/unsubscribe"; // Favorites pub const FAVORITE_GET_USER_FAVORITES: &str = "/favorite/getUserFavorites"; diff --git a/src-tauri/src/api/client.rs b/src-tauri/src/api/client.rs index e44994c27..cb37344e5 100644 --- a/src-tauri/src/api/client.rs +++ b/src-tauri/src/api/client.rs @@ -2076,6 +2076,34 @@ impl QobuzClient { Ok(()) } + /// Subscribe to a Qobuz playlist (follow it in the user's Qobuz library) + pub async fn subscribe_playlist(&self, playlist_id: u64) -> Result<()> { + let url = endpoints::build_url(paths::PLAYLIST_SUBSCRIBE); + + self.http + .get(&url) + .headers(self.authenticated_headers().await?) + .query(&[("playlist_id", playlist_id.to_string())]) + .send() + .await?; + + Ok(()) + } + + /// Unsubscribe from a Qobuz playlist + pub async fn unsubscribe_playlist(&self, playlist_id: u64) -> Result<()> { + let url = endpoints::build_url(paths::PLAYLIST_UNSUBSCRIBE); + + self.http + .get(&url) + .headers(self.authenticated_headers().await?) + .query(&[("playlist_id", playlist_id.to_string())]) + .send() + .await?; + + Ok(()) + } + /// Add tracks to a playlist pub async fn add_tracks_to_playlist(&self, playlist_id: u64, track_ids: &[u64]) -> Result<()> { let url = endpoints::build_url(paths::PLAYLIST_ADD_TRACKS); diff --git a/src-tauri/src/api/endpoints.rs b/src-tauri/src/api/endpoints.rs index 2c350dd01..8af274efc 100644 --- a/src-tauri/src/api/endpoints.rs +++ b/src-tauri/src/api/endpoints.rs @@ -41,6 +41,8 @@ pub mod paths { pub const PLAYLIST_DELETE_TRACKS: &str = "/playlist/deleteTracks"; pub const PLAYLIST_UPDATE: &str = "/playlist/update"; pub const PLAYLIST_GET_TAGS: &str = "/playlist/getTags"; + pub const PLAYLIST_SUBSCRIBE: &str = "/playlist/subscribe"; + pub const PLAYLIST_UNSUBSCRIBE: &str = "/playlist/unsubscribe"; // Favorites pub const FAVORITE_GET_USER_FAVORITES: &str = "/favorite/getUserFavorites"; diff --git a/src-tauri/src/commands_v2.rs b/src-tauri/src/commands_v2.rs index e6246cd82..bc996efb5 100644 --- a/src-tauri/src/commands_v2.rs +++ b/src-tauri/src/commands_v2.rs @@ -11296,6 +11296,36 @@ pub async fn v2_subscribe_playlist( .map_err(|e| format!("Failed to fetch created playlist: {}", e)) } +/// Subscribe to a Qobuz playlist (follow it in the user's Qobuz library). +/// Unlike v2_subscribe_playlist which copies tracks locally, this calls the +/// Qobuz API so the playlist appears in the user's account on all Qobuz clients. +#[tauri::command] +pub async fn v2_qobuz_subscribe_playlist( + playlist_id: u64, + state: State<'_, AppState>, +) -> Result<(), String> { + log::info!("Command: v2_qobuz_subscribe_playlist {}", playlist_id); + let client = state.client.read().await; + client + .subscribe_playlist(playlist_id) + .await + .map_err(|e| format!("Failed to subscribe to playlist: {}", e)) +} + +/// Unsubscribe from a Qobuz playlist. +#[tauri::command] +pub async fn v2_qobuz_unsubscribe_playlist( + playlist_id: u64, + state: State<'_, AppState>, +) -> Result<(), String> { + log::info!("Command: v2_qobuz_unsubscribe_playlist {}", playlist_id); + let client = state.client.read().await; + client + .unsubscribe_playlist(playlist_id) + .await + .map_err(|e| format!("Failed to unsubscribe from playlist: {}", e)) +} + #[tauri::command] #[allow(non_snake_case)] pub async fn v2_share_track_songlink( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6c9db38e6..6fb7f4d18 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1448,6 +1448,8 @@ pub fn run() { commands_v2::v2_save_image_url_to_file, commands_v2::v2_show_track_notification, commands_v2::v2_subscribe_playlist, + commands_v2::v2_qobuz_subscribe_playlist, + commands_v2::v2_qobuz_unsubscribe_playlist, commands_v2::v2_cache_track_for_offline, commands_v2::v2_cache_tracks_batch_for_offline, commands_v2::v2_start_legacy_migration, diff --git a/src/lib/components/views/PlaylistDetailView.svelte b/src/lib/components/views/PlaylistDetailView.svelte index d70e96778..a1d1fe212 100644 --- a/src/lib/components/views/PlaylistDetailView.svelte +++ b/src/lib/components/views/PlaylistDetailView.svelte @@ -1,5 +1,5 @@