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
This commit is contained in:
12 files changed
+163
-2
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Play, Shuffle, ListMusic, Search, X, ChevronDown, ChevronRight, ChevronUp, ImagePlus, Edit3, BarChart2, Heart, CloudDownload, ListPlus, GripVertical, CheckSquare } from 'lucide-svelte';
|
||||
import { ArrowLeft, Play, Shuffle, ListMusic, Search, X, ChevronDown, ChevronRight, ChevronUp, ImagePlus, Edit3, BarChart2, Heart, CloudDownload, ListPlus, GripVertical, CheckSquare, Bookmark } from 'lucide-svelte';
|
||||
import AlbumMenu from '../AlbumMenu.svelte';
|
||||
import PlaylistCollage from '../PlaylistCollage.svelte';
|
||||
import PlaylistModal from '../PlaylistModal.svelte';
|
||||
@@ -339,6 +339,12 @@
|
||||
// Show copy button only if: not own playlist AND not already copied
|
||||
let showCopyButton = $derived(!isOwnPlaylist && playlist !== null && !isCopied);
|
||||
|
||||
// Qobuz follow state (subscribe to playlist on Qobuz account)
|
||||
let isFollowing = $state(false);
|
||||
let isFollowBusy = $state(false);
|
||||
// Show follow button only for non-owned playlists
|
||||
let showFollowButton = $derived(!isOwnPlaylist && playlist !== null);
|
||||
|
||||
async function scrollToTrack(trackId: number) {
|
||||
await tick();
|
||||
// With virtualized list, find track index and scroll to its computed position
|
||||
@@ -908,6 +914,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFollowOnQobuz() {
|
||||
if (isFollowBusy || !playlist) return;
|
||||
isFollowBusy = true;
|
||||
try {
|
||||
if (isFollowing) {
|
||||
await invoke('v2_qobuz_unsubscribe_playlist', { playlistId: playlist.id });
|
||||
isFollowing = false;
|
||||
} else {
|
||||
await invoke('v2_qobuz_subscribe_playlist', { playlistId: playlist.id });
|
||||
isFollowing = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle Qobuz follow:', err);
|
||||
} finally {
|
||||
isFollowBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSort(field: SortField) {
|
||||
// Default and custom don't have direction toggles
|
||||
if (field === 'default' || field === 'custom') {
|
||||
@@ -2150,11 +2174,26 @@
|
||||
class:is-loading={isCopying}
|
||||
onclick={copyPlaylistToLibrary}
|
||||
disabled={isCopying}
|
||||
title="Copy to My Library"
|
||||
title={$t('playlist.copyToLibrary')}
|
||||
>
|
||||
<ListPlus size={18} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if showFollowButton}
|
||||
<button
|
||||
class="action-btn-circle"
|
||||
class:is-loading={isFollowBusy}
|
||||
onclick={toggleFollowOnQobuz}
|
||||
disabled={isFollowBusy}
|
||||
title={isFollowing ? $t('playlist.unfollowOnQobuz') : $t('playlist.followOnQobuz')}
|
||||
>
|
||||
<Bookmark
|
||||
size={18}
|
||||
color={isFollowing ? 'var(--accent-primary)' : 'currentColor'}
|
||||
fill={isFollowing ? 'var(--accent-primary)' : 'none'}
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
<AlbumMenu
|
||||
onPlayNext={handlePlayAllNext}
|
||||
onPlayLater={handlePlayAllLater}
|
||||
|
||||
@@ -388,6 +388,8 @@
|
||||
"newPlaylist": "Neue Playlist",
|
||||
"editPlaylist": "Playlist bearbeiten",
|
||||
"addToPlaylist": "Zur Playlist hinzufügen",
|
||||
"followOnQobuz": "Auf Qobuz folgen",
|
||||
"unfollowOnQobuz": "Auf Qobuz entfolgen",
|
||||
"enterName": "Bitte gib einen Playlist-Namen ein",
|
||||
"selectPlaylist": "Bitte wähle eine Playlist",
|
||||
"duplicates": {
|
||||
|
||||
@@ -410,6 +410,8 @@
|
||||
"makeOfflineConfirmDesc": "This will cache {count} tracks for offline listening. This may use significant disk space and take a while to complete.",
|
||||
"preparingPlaylistOffline": "Preparing {count} tracks from \"{name}\" for offline listening...",
|
||||
"copyToLibrary": "Copy to library",
|
||||
"followOnQobuz": "Follow on Qobuz",
|
||||
"unfollowOnQobuz": "Unfollow on Qobuz",
|
||||
"enterName": "Please enter a playlist name",
|
||||
"selectPlaylist": "Please select a playlist",
|
||||
"duplicates": {
|
||||
|
||||
@@ -410,6 +410,8 @@
|
||||
"makeOfflineConfirmDesc": "Esto almacenará {count} pistas para escuchar sin conexión. Puede usar espacio significativo en disco y tomar un tiempo en completarse.",
|
||||
"preparingPlaylistOffline": "Preparando {count} pistas de \"{name}\" para escuchar sin conexión...",
|
||||
"copyToLibrary": "Copiar a mi biblioteca",
|
||||
"followOnQobuz": "Seguir en Qobuz",
|
||||
"unfollowOnQobuz": "Dejar de seguir en Qobuz",
|
||||
"enterName": "Ingresa un nombre para la lista",
|
||||
"selectPlaylist": "Selecciona una lista",
|
||||
"duplicates": {
|
||||
|
||||
@@ -388,6 +388,8 @@
|
||||
"newPlaylist": "Nouvelle playlist",
|
||||
"editPlaylist": "Modifier la playlist",
|
||||
"addToPlaylist": "Ajouter à la playlist",
|
||||
"followOnQobuz": "Suivre sur Qobuz",
|
||||
"unfollowOnQobuz": "Ne plus suivre sur Qobuz",
|
||||
"enterName": "Veuillez entrer un nom de playlist",
|
||||
"selectPlaylist": "Veuillez sélectionner une playlist",
|
||||
"duplicates": {
|
||||
|
||||
@@ -410,6 +410,8 @@
|
||||
"makeOfflineConfirmDesc": "Isso armazenará {count} faixas em cache para ouvir offline. Pode usar bastante espaço em disco e levar algum tempo para concluir.",
|
||||
"preparingPlaylistOffline": "Preparando {count} faixas de \"{name}\" para ouvir offline...",
|
||||
"copyToLibrary": "Copiar para biblioteca",
|
||||
"followOnQobuz": "Seguir no Qobuz",
|
||||
"unfollowOnQobuz": "Deixar de seguir no Qobuz",
|
||||
"enterName": "Por favor, insira um nome para a playlist",
|
||||
"selectPlaylist": "Por favor, selecione uma playlist",
|
||||
"duplicates": {
|
||||
|
||||
Reference in new issue
Block a user