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,