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:
vicrodh committed 2026-03-19 11:36:56 -06:00
1 parent 5488839e97
commit 1ad16338fa
12 files changed
+163 -2

No files matched your search

+48
View File
@@ -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);
+2
View File
@@ -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";
+28
View File
@@ -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);
+2
View File
@@ -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";
+30
View File
@@ -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(
+2
View File
@@ -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}
+2
View File
@@ -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": {
+2
View File
@@ -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": {
+2
View File
@@ -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": {
+2
View File
@@ -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": {
+2
View File
@@ -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": {