feat(labels): follow / unfollow labels (mirrors Follow Artist)

Adds the same follow flow we already have for artists, applied to
labels. Confirmed against the inferred OpenAPI spec
(qbz-nix-docs/qobuz-api-inferred-openapi-v9.7.0.3.yaml) — the
existing /favorite/create, /favorite/delete and
/favorite/getUserFavorites endpoints already accept fav_type='label'
and label_ids, so no new Qobuz endpoints were needed.

Backend
- favorites_cache: new favorite_labels SQLite table + add / remove /
  sync / get methods alongside the artist counterparts. clear_all
  drops the new table on logout.
- commands_v2::favorites: 4 new commands —
  v2_get_cached_favorite_labels, v2_sync_cached_favorite_labels,
  v2_cache_favorite_label, v2_uncache_favorite_label. Same wiring as
  the artist commands.
- lib.rs invoke_handler registers the four new commands.

Frontend
- src/lib/stores/labelFavoritesStore.ts: clone of
  artistFavoritesStore — same in-memory Set, same isLoaded flag,
  toggle calls /favorite/create with fav_type=label, sync paginates
  /favorite/getUserFavorites?type=labels.
- routes/+page.svelte: loadLabelFavorites() called alongside the
  other three after login is confirmed.
- LabelView.svelte: heart-shape Follow button in the label header
  info column, mirroring ArtistDetailView's .artist-actions
  treatment. Reactive to the store via labelFavoritesVersion bump
  so any future Favorites view that toggles a label will keep this
  view in sync.
- i18n: label.follow / label.unfollow in all five locales.

cc
This commit is contained in:
vicrodh committed 2026-04-14 09:00:11 -06:00
1 parent b9a1f9be70
commit a9a9ba5050
11 files changed
+457 -5

No files matched your search

+76
View File
@@ -351,3 +351,79 @@ pub async fn v2_uncache_favorite_artist(
.remove_favorite_artist(artistId)
.map_err(|e| RuntimeError::Internal(e))
}
// ============ Label favorites cache (mirrors artist) ============
/// Get cached favorite labels (V2)
#[tauri::command]
pub async fn v2_get_cached_favorite_labels(
cache_state: State<'_, crate::config::favorites_cache::FavoritesCacheState>,
) -> Result<Vec<i64>, RuntimeError> {
let guard = cache_state
.store
.lock()
.map_err(|_| RuntimeError::Internal("Failed to lock favorites cache".to_string()))?;
let store = guard
.as_ref()
.ok_or_else(|| RuntimeError::Internal("No active session".to_string()))?;
store
.get_favorite_label_ids()
.map_err(|e| RuntimeError::Internal(e))
}
/// Sync cached favorite labels (V2) — replaces the entire cached set
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_sync_cached_favorite_labels(
labelIds: Vec<i64>,
cache_state: State<'_, crate::config::favorites_cache::FavoritesCacheState>,
) -> Result<(), RuntimeError> {
let guard = cache_state
.store
.lock()
.map_err(|_| RuntimeError::Internal("Failed to lock favorites cache".to_string()))?;
let store = guard
.as_ref()
.ok_or_else(|| RuntimeError::Internal("No active session".to_string()))?;
store
.sync_favorite_labels(&labelIds)
.map_err(|e| RuntimeError::Internal(e))
}
/// Cache a single favorite label (V2)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_cache_favorite_label(
labelId: i64,
cache_state: State<'_, crate::config::favorites_cache::FavoritesCacheState>,
) -> Result<(), RuntimeError> {
let guard = cache_state
.store
.lock()
.map_err(|_| RuntimeError::Internal("Failed to lock favorites cache".to_string()))?;
let store = guard
.as_ref()
.ok_or_else(|| RuntimeError::Internal("No active session".to_string()))?;
store
.add_favorite_label(labelId)
.map_err(|e| RuntimeError::Internal(e))
}
/// Uncache a favorite label (V2)
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_uncache_favorite_label(
labelId: i64,
cache_state: State<'_, crate::config::favorites_cache::FavoritesCacheState>,
) -> Result<(), RuntimeError> {
let guard = cache_state
.store
.lock()
.map_err(|_| RuntimeError::Internal("Failed to lock favorites cache".to_string()))?;
let store = guard
.as_ref()
.ok_or_else(|| RuntimeError::Internal("No active session".to_string()))?;
store
.remove_favorite_label(labelId)
.map_err(|e| RuntimeError::Internal(e))
}
+89
View File
@@ -33,6 +33,12 @@ pub struct CachedFavoriteArtist {
pub artist_id: i64,
}
/// Represents a cached favorite label
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedFavoriteLabel {
pub label_id: i64,
}
pub struct FavoritesCacheStore {
conn: Connection,
}
@@ -77,6 +83,18 @@ impl FavoritesCacheStore {
)
.map_err(|e| format!("Failed to create favorite_artists table: {}", e))?;
// Labels — added as part of the Follow Label feature; same shape as
// favorite_artists. CREATE IF NOT EXISTS is the migration story
// for existing databases (no separate ALTER needed).
conn.execute(
"CREATE TABLE IF NOT EXISTS favorite_labels (
label_id INTEGER PRIMARY KEY,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
)",
[],
)
.map_err(|e| format!("Failed to create favorite_labels table: {}", e))?;
Ok(Self { conn })
}
@@ -296,6 +314,74 @@ impl FavoritesCacheStore {
Ok(())
}
// ============ Label favorites ============
pub fn get_favorite_label_ids(&self) -> Result<Vec<i64>, String> {
let mut stmt = self
.conn
.prepare("SELECT label_id FROM favorite_labels")
.map_err(|e| format!("Failed to prepare query: {}", e))?;
let rows = stmt
.query_map([], |row| row.get(0))
.map_err(|e| format!("Failed to query favorite labels: {}", e))?;
let mut ids = Vec::new();
for row in rows {
ids.push(row.map_err(|e| format!("Failed to read row: {}", e))?);
}
Ok(ids)
}
pub fn is_label_favorite(&self, label_id: i64) -> Result<bool, String> {
let mut stmt = self
.conn
.prepare("SELECT 1 FROM favorite_labels WHERE label_id = ?1")
.map_err(|e| format!("Failed to prepare query: {}", e))?;
let exists = stmt
.exists(params![label_id])
.map_err(|e| format!("Failed to check favorite: {}", e))?;
Ok(exists)
}
pub fn add_favorite_label(&self, label_id: i64) -> Result<(), String> {
self.conn
.execute(
"INSERT OR IGNORE INTO favorite_labels (label_id) VALUES (?1)",
params![label_id],
)
.map_err(|e| format!("Failed to add favorite label: {}", e))?;
Ok(())
}
pub fn remove_favorite_label(&self, label_id: i64) -> Result<(), String> {
self.conn
.execute(
"DELETE FROM favorite_labels WHERE label_id = ?1",
params![label_id],
)
.map_err(|e| format!("Failed to remove favorite label: {}", e))?;
Ok(())
}
pub fn sync_favorite_labels(&self, label_ids: &[i64]) -> Result<(), String> {
self.conn
.execute("DELETE FROM favorite_labels", [])
.map_err(|e| format!("Failed to clear favorite labels: {}", e))?;
for &label_id in label_ids {
self.conn
.execute(
"INSERT INTO favorite_labels (label_id) VALUES (?1)",
params![label_id],
)
.map_err(|e| format!("Failed to insert favorite label: {}", e))?;
}
Ok(())
}
// ============ Clear all (for logout) ============
pub fn clear_all(&self) -> Result<(), String> {
@@ -308,6 +394,9 @@ impl FavoritesCacheStore {
self.conn
.execute("DELETE FROM favorite_artists", [])
.map_err(|e| format!("Failed to clear favorite artists: {}", e))?;
self.conn
.execute("DELETE FROM favorite_labels", [])
.map_err(|e| format!("Failed to clear favorite labels: {}", e))?;
Ok(())
}
}
+4
View File
@@ -1688,6 +1688,10 @@ pub fn run() {
commands_v2::v2_sync_cached_favorite_artists,
commands_v2::v2_cache_favorite_artist,
commands_v2::v2_uncache_favorite_artist,
commands_v2::v2_get_cached_favorite_labels,
commands_v2::v2_sync_cached_favorite_labels,
commands_v2::v2_cache_favorite_label,
commands_v2::v2_uncache_favorite_label,
commands_v2::v2_share_track_songlink,
commands_v2::v2_share_album_songlink,
commands_v2::v2_library_backfill_downloads,
+81
View File
@@ -20,6 +20,12 @@
isTrackToggling,
toggleTrackFavorite
} from '$lib/stores/favoritesStore';
import {
subscribe as subscribeLabelFavorites,
isLabelFavorite,
isLabelToggling,
toggleLabelFavorite
} from '$lib/stores/labelFavoritesStore';
import type { QobuzAlbum, LabelPageData, LabelExploreItem, DisplayTrack } from '$lib/types';
interface Track {
@@ -668,6 +674,23 @@
return albumOfflineCacheStatuses.get(albumId) || false;
}
// Label follow state — driven entirely by the labelFavoritesStore so any
// toggle elsewhere (e.g. a future favorites view) updates this view too.
let labelFavoritesVersion = $state(0);
let unsubLabelFavorites: (() => void) | null = null;
const labelIsFavorite = $derived.by(() => {
void labelFavoritesVersion;
return isLabelFavorite(labelId);
});
const labelIsToggling = $derived.by(() => {
void labelFavoritesVersion;
return isLabelToggling(labelId);
});
async function handleToggleLabelFavorite() {
await toggleLabelFavorite(labelId);
}
onMount(() => {
loadLabelPage();
loadLabelAlbumsAndDescription();
@@ -675,10 +698,14 @@
unsubFavorites = subscribeFavorites(() => {
trackFavoritesVersion++;
});
unsubLabelFavorites = subscribeLabelFavorites(() => {
labelFavoritesVersion++;
});
});
onDestroy(() => {
unsubFavorites?.();
unsubLabelFavorites?.();
jumpObserver?.disconnect();
});
@@ -809,6 +836,24 @@
{descriptionExpanded ? $t('label.readLess') : $t('label.readMore')}
</button>
{/if}
<!-- Follow Label — same heart/circle treatment as ArtistDetailView -->
<div class="label-actions">
<button
class="favorite-btn"
class:is-favorite={labelIsFavorite}
onclick={handleToggleLabelFavorite}
disabled={labelIsToggling}
title={labelIsFavorite ? $t('label.unfollow') : $t('label.follow')}
aria-label={labelIsFavorite ? $t('label.unfollow') : $t('label.follow')}
>
{#if labelIsFavorite}
<Heart size={24} fill="var(--accent-primary)" color="var(--accent-primary)" />
{:else}
<Heart size={24} />
{/if}
</button>
</div>
</div>
</header>
@@ -1294,6 +1339,42 @@
/* Header */
.label-header { display: flex; gap: 24px; margin-bottom: 40px; }
/* Follow button — visual parity with ArtistDetailView .artist-actions */
.label-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.favorite-btn {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background: var(--bg-tertiary);
border: none;
border-radius: 50%;
cursor: pointer;
color: var(--text-muted);
transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
flex-shrink: 0;
}
.favorite-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--accent-primary);
}
.favorite-btn.is-favorite {
background: rgba(var(--accent-primary-rgb, 139, 92, 246), 0.15);
}
.favorite-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.label-image-wrapper {
width: 180px; height: 180px; border-radius: 50%;
overflow: hidden; flex-shrink: 0; background: var(--bg-tertiary);
+3 -1
View File
@@ -138,7 +138,9 @@
"readLess": "WENIGER",
"seeAll": "Alle anzeigen",
"showMore": "Mehr anzeigen",
"showLess": "Weniger anzeigen"
"showLess": "Weniger anzeigen",
"follow": "Label folgen",
"unfollow": "Nicht mehr folgen"
},
"labelReleases": {
"albumCount": "{count} Alben",
+3 -1
View File
@@ -138,7 +138,9 @@
"readLess": "READ LESS",
"seeAll": "See all",
"showMore": "Show more",
"showLess": "Show less"
"showLess": "Show less",
"follow": "Follow label",
"unfollow": "Unfollow label"
},
"labelReleases": {
"albumCount": "{count} albums",
+3 -1
View File
@@ -138,7 +138,9 @@
"readLess": "LEER MENOS",
"seeAll": "Ver todo",
"showMore": "Ver más",
"showLess": "Ver menos"
"showLess": "Ver menos",
"follow": "Seguir sello",
"unfollow": "Dejar de seguir"
},
"labelReleases": {
"albumCount": "{count} albums",
+3 -1
View File
@@ -138,7 +138,9 @@
"readLess": "Lire moins",
"seeAll": "Voir tout",
"showMore": "Voir plus",
"showLess": "Voir moins"
"showLess": "Voir moins",
"follow": "Suivre le label",
"unfollow": "Ne plus suivre"
},
"labelReleases": {
"albumCount": "{count} albums",
+3 -1
View File
@@ -138,7 +138,9 @@
"readLess": "LEIA MENOS",
"seeAll": "Ver tudo",
"showMore": "Mostrar mais",
"showLess": "Mostrar menos"
"showLess": "Mostrar menos",
"follow": "Seguir selo",
"unfollow": "Deixar de seguir"
},
"labelReleases": {
"albumCount": "{count} álbuns",
+190
View File
@@ -0,0 +1,190 @@
/**
* Label Favorites Store
*
* Centralized store for tracking favorite (followed) labels. Mirrors
* artistFavoritesStore one-for-one — same SQLite-backed cache, same
* sync strategy, same toggle flow. Added as part of the Follow Label
* feature; the underlying Qobuz endpoints (/favorite/create,
* /favorite/delete, /favorite/getUserFavorites) already accept
* `label_ids` / `fav_type=labels` per
* qbz-nix-docs/qobuz-api-inferred-openapi-v9.7.0.3.yaml.
*/
import { invoke } from '@tauri-apps/api/core';
import { skipIfRemote } from '$lib/services/commandRouter';
let favoriteLabelIds = new Set<number>();
let togglingLabelIds = new Set<number>();
let isLoaded = false;
let isLoading = false;
const listeners = new Set<() => void>();
function notifyListeners(): void {
for (const listener of listeners) {
listener();
}
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function isLabelFavorite(labelId: number): boolean {
return favoriteLabelIds.has(labelId);
}
export function isLabelToggling(labelId: number): boolean {
return togglingLabelIds.has(labelId);
}
export function getFavoriteLabelIds(): number[] {
return Array.from(favoriteLabelIds);
}
export function isFavoritesLoaded(): boolean {
return isLoaded;
}
/**
* Load label favorites from local cache first, then sync from API.
*/
export async function loadLabelFavorites(): Promise<void> {
if (skipIfRemote()) return;
if (isLoading) return;
isLoading = true;
try {
try {
const cachedIds = await invoke<number[]>('v2_get_cached_favorite_labels');
if (cachedIds.length > 0) {
favoriteLabelIds = new Set(cachedIds);
isLoaded = true;
notifyListeners();
console.log(`[LabelFavorites] Loaded ${cachedIds.length} labels from local cache`);
}
} catch (cacheErr) {
console.debug('[LabelFavorites] No local cache available:', cacheErr);
}
await syncFromApi();
} catch (err) {
console.error('[LabelFavorites] Failed to load favorites:', err);
if (!isLoaded) {
favoriteLabelIds = new Set();
isLoaded = true;
notifyListeners();
}
} finally {
isLoading = false;
}
}
/**
* Sync label favorites from Qobuz API to local cache.
*/
export async function syncFromApi(): Promise<void> {
if (skipIfRemote()) return;
try {
const allLabelIds: number[] = [];
let offset = 0;
const limit = 500;
while (true) {
const result = await invoke<{ labels?: { items: Array<{ id: number }>; total?: number } }>('v2_get_favorites', {
favType: 'labels',
limit,
offset
});
const items = result.labels?.items ?? [];
if (!items.length) break;
allLabelIds.push(...items.map(item => item.id));
offset += items.length;
if (items.length < limit) break;
if (result.labels?.total && offset >= result.labels.total) break;
}
await invoke('v2_sync_cached_favorite_labels', { labelIds: allLabelIds });
favoriteLabelIds = new Set(allLabelIds);
isLoaded = true;
notifyListeners();
console.log(`[LabelFavorites] Synced ${allLabelIds.length} labels from API`);
} catch (err) {
console.error('[LabelFavorites] Failed to sync from API:', err);
throw err;
}
}
/**
* Toggle label favorite status: API call first → on success → update
* local cache → notify UI.
*/
export async function toggleLabelFavorite(labelId: number): Promise<boolean> {
if (skipIfRemote()) return favoriteLabelIds.has(labelId);
if (togglingLabelIds.has(labelId)) {
return favoriteLabelIds.has(labelId);
}
const wasFavorite = favoriteLabelIds.has(labelId);
const newState = !wasFavorite;
togglingLabelIds.add(labelId);
notifyListeners();
try {
if (newState) {
await invoke('v2_add_favorite', { favType: 'label', itemId: String(labelId) });
await invoke('v2_cache_favorite_label', { labelId });
favoriteLabelIds.add(labelId);
} else {
await invoke('v2_remove_favorite', { favType: 'label', itemId: String(labelId) });
await invoke('v2_uncache_favorite_label', { labelId });
favoriteLabelIds.delete(labelId);
}
return newState;
} catch (err) {
console.error('[LabelFavorites] Failed to toggle favorite:', err);
return wasFavorite;
} finally {
togglingLabelIds.delete(labelId);
notifyListeners();
}
}
/**
* Sync local cache from an array of label IDs (e.g. after fetching the
* full favorites page elsewhere).
*/
export async function syncCache(labelIds: number[]): Promise<void> {
if (skipIfRemote()) return;
try {
await invoke('v2_sync_cached_favorite_labels', { labelIds });
favoriteLabelIds = new Set(labelIds);
notifyListeners();
} catch (err) {
console.error('[LabelFavorites] Failed to sync cache:', err);
}
}
export function markAsFavorite(labelId: number): void {
if (skipIfRemote()) return;
if (!favoriteLabelIds.has(labelId)) {
favoriteLabelIds.add(labelId);
invoke('v2_cache_favorite_label', { labelId }).catch(() => {});
notifyListeners();
}
}
export function unmarkAsFavorite(labelId: number): void {
if (skipIfRemote()) return;
if (favoriteLabelIds.has(labelId)) {
favoriteLabelIds.delete(labelId);
invoke('v2_uncache_favorite_label', { labelId }).catch(() => {});
notifyListeners();
}
}
+2
View File
@@ -153,6 +153,7 @@
import { loadFavorites } from '$lib/stores/favoritesStore';
import { loadAlbumFavorites } from '$lib/stores/albumFavoritesStore';
import { loadArtistFavorites } from '$lib/stores/artistFavoritesStore';
import { loadLabelFavorites } from '$lib/stores/labelFavoritesStore';
import { getDefaultFavoritesTab } from '$lib/utils/favorites';
import { platform } from '$lib/utils/platform';
import type { FavoritesPreferences, ResolvedMusician } from '$lib/types';
@@ -3664,6 +3665,7 @@
loadFavorites(); // Track favorites
loadAlbumFavorites(); // Album favorites
loadArtistFavorites(); // Artist favorites
loadLabelFavorites(); // Label favorites
// Refresh offline status now that we're logged in
refreshOfflineStatus().catch(err => console.debug('[Offline] Status refresh deferred:', err));