//! Library > Favorites controller — fetches the user's saved //! tracks / albums / artists via `QbzCore::get_favorites` and pushes //! them into `FavoritesState`. Mirrors Tauri's FavoritesView.svelte //! data flow: each tab is fetched lazily the first time it is opened. //! //! `get_favorites` returns a raw JSON value shaped //! `{ : { items: [...], total: N } }`; this module parses the //! relevant branch into typed qbz-models items and maps them to the //! Slint row/card structs. use std::sync::{Arc, LazyLock, Mutex}; use qbz_app::shell::AppRuntime; use qbz_core::FrontendAdapter; use qbz_models::{Album, Artist, Playlist, Track}; use serde::Deserialize; use slint::{ComponentHandle, Model, ModelRc, VecModel}; use crate::album_map::{self, map_album, to_item, AlbumCard}; use crate::artwork::{ArtworkJob, ArtworkTarget}; use crate::{ AlbumCardItem, AppWindow, FavoriteArtistItem, FavoriteLabelItem, FavoritePlaylistItem, FavoritesState, TrackItem, }; /// Page size — matches Tauri's FAVORITES_PAGE_SIZE. We fetch one /// page on tab open (favorites lists are typically small; full /// pagination can come later). pub const PAGE_SIZE: u32 = 500; /// Hard ceiling on favorites pulled across all pages (mirrors Tauri's /// FAVORITES_PAGE_SIZE * FAVORITES_MAX_PAGES ceiling). const MAX_ITEMS: usize = 10_000; /// The loaded favorite tracks as a play-ready queue source (Play all / /// Shuffle). Set on the UI thread by `apply_favorites`. static FAV_CURRENT: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); /// Which favorites tab to load. #[derive(Clone, Copy, PartialEq, Eq)] pub enum FavTab { Tracks, Albums, Artists, Playlists, Labels, } impl FavTab { pub fn from_route(route: &str) -> Option { Self::from_tab_id(route.strip_prefix("favorites-")?) } pub fn from_tab_id(id: &str) -> Option { match id { "tracks" => Some(Self::Tracks), "albums" => Some(Self::Albums), "artists" => Some(Self::Artists), "playlists" => Some(Self::Playlists), "labels" => Some(Self::Labels), _ => None, } } /// The Qobuz favType string + the JSON branch key (for the /// get_favorites-backed tabs). fn key(self) -> &'static str { match self { Self::Tracks => "tracks", Self::Albums => "albums", Self::Artists => "artists", Self::Playlists => "playlists", Self::Labels => "labels", } } } /// Favorites-labels response item — the qbz-models `Label` is just /// {id, name}, but the favorites payload carries an image + count, /// so parse into this richer local shape. #[derive(Deserialize)] struct FavLabel { #[serde(default)] id: u64, #[serde(default)] name: String, #[serde(default)] albums_count: Option, } pub enum FavData { Tracks { items: Vec, play: Vec, total: usize }, Albums { items: Vec, total: usize }, Artists { items: Vec, total: usize }, Playlists { items: Vec, total: usize }, Labels { items: Vec, total: usize }, } #[derive(Clone)] pub struct TrackCard { pub id: String, pub title: String, pub artist: String, pub artist_id: String, pub album: String, pub album_id: String, pub duration: String, pub quality_tier: String, pub explicit: bool, pub artwork_url: String, } #[derive(Clone)] pub struct ArtistCard { pub id: String, pub name: String, pub albums_line: String, pub image_url: String, } #[derive(Clone)] pub struct PlaylistCard { pub id: String, pub name: String, pub owner: String, pub tracks_line: String, pub cover_url: String, } #[derive(Clone)] pub struct LabelCard { pub id: String, pub name: String, pub albums_line: String, } /// Fetch + parse one favorites tab. pub async fn load_favorites( runtime: &Arc>, tab: FavTab, ) -> Result where A: FrontendAdapter + Send + Sync + 'static, { // Playlists come from /playlist/getUserPlaylists, not the // getUserFavorites envelope — handle them first. if tab == FavTab::Playlists { let playlists = runtime .core() .get_user_playlists() .await .map_err(|e| e.to_string())?; let total = playlists.len(); return Ok(FavData::Playlists { items: playlists.into_iter().map(map_playlist).collect(), total, }); } // Page through the favorites until the API is exhausted (mirrors // Tauri's fetchAllFavorites: keep pulling until a short page or // offset >= total), capped at MAX_ITEMS so a pathological library // can't loop forever. let mut total: usize; let mut all_items: Vec = Vec::new(); let mut offset = 0u32; loop { let value = runtime .core() .get_favorites(tab.key(), PAGE_SIZE, offset) .await .map_err(|e| e.to_string())?; let branch = value.get(tab.key()); total = branch .and_then(|b| b.get("total")) .and_then(|t| t.as_u64()) .unwrap_or(0) as usize; let page: Vec = branch .and_then(|b| b.get("items")) .and_then(|i| i.as_array()) .cloned() .unwrap_or_default(); let page_len = page.len(); all_items.extend(page); offset += page_len as u32; let exhausted = page_len < PAGE_SIZE as usize || (total > 0 && offset as usize >= total) || all_items.len() >= MAX_ITEMS; if exhausted { break; } } let items = serde_json::Value::Array(all_items); Ok(match tab { FavTab::Tracks => { let tracks: Vec = serde_json::from_value(items).unwrap_or_default(); let play = tracks.clone(); FavData::Tracks { items: tracks.into_iter().map(map_track).collect(), play, total, } } FavTab::Albums => { let albums: Vec = serde_json::from_value(items).unwrap_or_default(); FavData::Albums { items: albums.into_iter().map(map_album).collect(), total, } } FavTab::Artists => { let artists: Vec = serde_json::from_value(items).unwrap_or_default(); FavData::Artists { items: artists.into_iter().map(map_artist).collect(), total, } } FavTab::Labels => { let labels: Vec = serde_json::from_value(items).unwrap_or_default(); FavData::Labels { items: labels.into_iter().map(map_label).collect(), total, } } FavTab::Playlists => unreachable!("handled above"), }) } fn mmss(secs: u32) -> String { format!("{}:{:02}", secs / 60, secs % 60) } fn map_track(track: Track) -> TrackCard { let mut title = track.title; if let Some(version) = track.version.as_ref().filter(|v| !v.is_empty()) { title = format!("{title} ({version})"); } let artwork_url = track .album .as_ref() .and_then(|a| a.image.best().cloned()) .unwrap_or_default(); let album = track .album .as_ref() .map(|a| a.title.clone()) .unwrap_or_default(); let album_id = track.album.as_ref().map(|a| a.id.clone()).unwrap_or_default(); let (artist, artist_id) = track .performer .map(|p| (p.name, p.id.to_string())) .unwrap_or_default(); TrackCard { id: track.id.to_string(), title, artist, artist_id, album, album_id, duration: mmss(track.duration), quality_tier: album_map::tier(track.maximum_bit_depth).to_string(), explicit: track.parental_warning, artwork_url, } } fn map_artist(artist: Artist) -> ArtistCard { let albums_line = match artist.albums_count { Some(n) if n > 0 => format!("{} albums", n), _ => String::new(), }; ArtistCard { id: artist.id.to_string(), name: artist.name, albums_line, image_url: artist .image .and_then(|img| img.best().cloned()) .unwrap_or_default(), } } fn map_playlist(playlist: Playlist) -> PlaylistCard { // The highest-resolution non-empty cover list wins (images300 > // images150 > images), first entry as the tile cover. let cover_url = [&playlist.images300, &playlist.images150, &playlist.images] .into_iter() .flatten() .find(|v| !v.is_empty()) .and_then(|list| list.first().cloned()) .unwrap_or_default(); PlaylistCard { id: playlist.id.to_string(), name: playlist.name, owner: playlist.owner.name, tracks_line: format!("{} tracks", playlist.tracks_count), cover_url, } } fn map_label(label: FavLabel) -> LabelCard { let albums_line = match label.albums_count { Some(n) if n > 0 => format!("{} releases", n), _ => String::new(), }; LabelCard { id: label.id.to_string(), name: label.name, albums_line, } } pub fn apply_favorites(window: &AppWindow, data: FavData) { let state = window.global::(); match data { FavData::Tracks { items, play, total } => { if let Ok(mut current) = FAV_CURRENT.lock() { *current = play; } let rows: Vec = items .into_iter() .map(|t| TrackItem { id: t.id.into(), number: "".into(), title: t.title.into(), artist: t.artist.into(), album: t.album.into(), duration: t.duration.into(), quality_tier: t.quality_tier.into(), explicit: t.explicit, selected: false, artwork_url: t.artwork_url.into(), artwork: slint::Image::default(), // Everything in the Favorites > Tracks tab is, by // definition, a favorite. is_favorite: true, artist_id: t.artist_id.into(), album_id: t.album_id.into(), }) .collect(); // `tracks` is the full set the artwork pipeline targets; // `tracks-visible` (what the list renders) shares the same // model until a search filter forks it, so artwork stays live. let model = ModelRc::new(VecModel::from(rows)); state.set_tracks(model.clone()); state.set_tracks_visible(model); state.set_tracks_total(total as i32); state.set_tracks_search("".into()); } FavData::Albums { items, total } => { let cards: Vec = items.into_iter().map(to_item).collect(); state.set_albums(ModelRc::new(VecModel::from(cards))); state.set_albums_total(total as i32); } FavData::Artists { items, total } => { let cards: Vec = items .into_iter() .map(|a| FavoriteArtistItem { id: a.id.into(), name: a.name.into(), albums_line: a.albums_line.into(), image_url: a.image_url.into(), image: slint::Image::default(), }) .collect(); state.set_artists(ModelRc::new(VecModel::from(cards))); state.set_artists_total(total as i32); } FavData::Playlists { items, total } => { let cards: Vec = items .into_iter() .map(|p| FavoritePlaylistItem { id: p.id.into(), name: p.name.into(), owner: p.owner.into(), tracks_line: p.tracks_line.into(), cover_url: p.cover_url.into(), cover: slint::Image::default(), }) .collect(); state.set_playlists(ModelRc::new(VecModel::from(cards))); state.set_playlists_total(total as i32); } FavData::Labels { items, total } => { let rows: Vec = items .into_iter() .map(|l| FavoriteLabelItem { id: l.id.into(), name: l.name.into(), albums_line: l.albums_line.into(), }) .collect(); state.set_labels(ModelRc::new(VecModel::from(rows))); state.set_labels_total(total as i32); } } state.set_loading(false); } /// Re-derive the rendered Tracks list (`tracks-visible`) from the full /// `tracks` set and the search query. An empty query shares the full /// model so artwork keeps updating in place (the LabelState albums/visible /// pattern); a query forks a filtered clone (each row carries its already /// decoded artwork, so no re-fetch). pub fn derive_tracks(window: &AppWindow) { let state = window.global::(); let query_owned = state.get_tracks_search().to_lowercase(); let query = query_owned.trim(); let all = state.get_tracks(); if query.is_empty() { state.set_tracks_visible(all); return; } let filtered: Vec = (0..all.row_count()) .filter_map(|i| all.row_data(i)) .filter(|t| { t.title.to_lowercase().contains(query) || t.artist.to_lowercase().contains(query) || t.album.to_lowercase().contains(query) }) .collect(); state.set_tracks_visible(ModelRc::new(VecModel::from(filtered))); } /// The loaded favorite tracks as a play-ready queue (Play all). pub fn play_tracks() -> Vec { FAV_CURRENT.lock().map(|c| c.clone()).unwrap_or_default() } /// The favorite tracks in a fresh random order (Shuffle). Mirrors /// playlist::shuffled_tracks (time-seeded xorshift Fisher-Yates). pub fn shuffled_tracks() -> Vec { let mut tracks = play_tracks(); let mut seed = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(1) | 1; for i in (1..tracks.len()).rev() { seed ^= seed << 13; seed ^= seed >> 7; seed ^= seed << 17; let j = (seed % (i as u64 + 1)) as usize; tracks.swap(i, j); } tracks } pub fn reset_loading(window: &AppWindow) { window.global::().set_loading(true); } /// Artwork jobs for the freshly loaded tab. pub fn artwork_jobs(data: &FavData) -> Vec { match data { FavData::Tracks { items, .. } => items .iter() .enumerate() .filter(|(_, t)| !t.artwork_url.is_empty()) .map(|(i, t)| ArtworkJob { url: t.artwork_url.clone(), target: ArtworkTarget::FavoriteTrack { index: i }, }) .collect(), FavData::Albums { items, .. } => items .iter() .enumerate() .filter(|(_, a)| !a.artwork_url.is_empty()) .map(|(i, a)| ArtworkJob { url: a.artwork_url.clone(), target: ArtworkTarget::FavoriteAlbum { index: i }, }) .collect(), FavData::Artists { items, .. } => items .iter() .enumerate() .filter(|(_, a)| !a.image_url.is_empty()) .map(|(i, a)| ArtworkJob { url: a.image_url.clone(), target: ArtworkTarget::FavoriteArtist { index: i }, }) .collect(), FavData::Playlists { items, .. } => items .iter() .enumerate() .filter(|(_, p)| !p.cover_url.is_empty()) .map(|(i, p)| ArtworkJob { url: p.cover_url.clone(), target: ArtworkTarget::FavoritePlaylist { index: i }, }) .collect(), // Labels render an icon, no remote artwork. FavData::Labels { .. } => Vec::new(), } }