From a27317471662c76dde4fd0a300e7a8b1ca7f500e Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:02:37 -0600 Subject: [PATCH] feat(award): use /award/getAlbums for the grid + follow award as entity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two behavior fixes for AwardView based on user feedback. 1. Grid content. The previous implementation rendered the releases array from /award/page, which is user-scoped (only surfaces awards on albums the user already favorited), so the grid came up empty in almost every case. Switch to /award/getAlbums which is the public catalog listing with pagination. /award/page is still called, but only to hydrate the hero (name, image, magazine). 2. Follow-the-award. Awards themselves are followable on Qobuz mobile (it's what feeds the 'Awards' tab of Release Watch). Add: - favorite_awards table + store methods in favorites_cache.rs - 4 V2 commands (get/sync/cache/uncache) mirroring the label ones - awardFavoritesStore.ts — clone of labelFavoritesStore with string ids (Qobuz mobile API passes award_ids as strings) - Follow/Unfollow heart button in AwardView hero, matching the ArtistDetailView / LabelView visual - loadAwardFavorites() wired into the login bootstrap alongside loadArtistFavorites / loadLabelFavorites - i18n keys (award.follow / award.unfollow) Also adds a Ver más button for paginated loading of the album grid. cc --- src-tauri/src/commands_v2/favorites.rs | 72 +++++ src-tauri/src/config/favorites_cache.rs | 83 ++++++ src-tauri/src/lib.rs | 4 + src/lib/components/views/AwardView.svelte | 319 +++++++++++++++------- src/lib/i18n/locales/de.json | 4 +- src/lib/i18n/locales/en.json | 4 +- src/lib/i18n/locales/es.json | 4 +- src/lib/i18n/locales/fr.json | 4 +- src/lib/i18n/locales/pt.json | 4 +- src/lib/stores/awardFavoritesStore.ts | 155 +++++++++++ src/routes/+page.svelte | 2 + 11 files changed, 546 insertions(+), 109 deletions(-) create mode 100644 src/lib/stores/awardFavoritesStore.ts diff --git a/src-tauri/src/commands_v2/favorites.rs b/src-tauri/src/commands_v2/favorites.rs index 616614d1f..59c3e49a2 100644 --- a/src-tauri/src/commands_v2/favorites.rs +++ b/src-tauri/src/commands_v2/favorites.rs @@ -427,3 +427,75 @@ pub async fn v2_uncache_favorite_label( .remove_favorite_label(labelId) .map_err(|e| RuntimeError::Internal(e)) } + +// ============ Award favorites cache (mirrors label) ============ + +#[tauri::command] +pub async fn v2_get_cached_favorite_awards( + 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 + .get_favorite_award_ids() + .map_err(|e| RuntimeError::Internal(e)) +} + +#[tauri::command] +#[allow(non_snake_case)] +pub async fn v2_sync_cached_favorite_awards( + awardIds: Vec, + 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_awards(&awardIds) + .map_err(|e| RuntimeError::Internal(e)) +} + +#[tauri::command] +#[allow(non_snake_case)] +pub async fn v2_cache_favorite_award( + awardId: String, + 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_award(&awardId) + .map_err(|e| RuntimeError::Internal(e)) +} + +#[tauri::command] +#[allow(non_snake_case)] +pub async fn v2_uncache_favorite_award( + awardId: String, + 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_award(&awardId) + .map_err(|e| RuntimeError::Internal(e)) +} diff --git a/src-tauri/src/config/favorites_cache.rs b/src-tauri/src/config/favorites_cache.rs index 7c22e78f2..203697e3f 100644 --- a/src-tauri/src/config/favorites_cache.rs +++ b/src-tauri/src/config/favorites_cache.rs @@ -95,6 +95,18 @@ impl FavoritesCacheStore { ) .map_err(|e| format!("Failed to create favorite_labels table: {}", e))?; + // Awards — added as part of the Follow Award feature. award_id + // is TEXT because /favorite/create?award_ids=... takes string + // identifiers and the Android DTO declares id as String?. + conn.execute( + "CREATE TABLE IF NOT EXISTS favorite_awards ( + award_id TEXT PRIMARY KEY, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + )", + [], + ) + .map_err(|e| format!("Failed to create favorite_awards table: {}", e))?; + Ok(Self { conn }) } @@ -382,6 +394,74 @@ impl FavoritesCacheStore { Ok(()) } + // ============ Award favorites ============ + + pub fn get_favorite_award_ids(&self) -> Result, String> { + let mut stmt = self + .conn + .prepare("SELECT award_id FROM favorite_awards") + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| format!("Failed to query favorite awards: {}", 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_award_favorite(&self, award_id: &str) -> Result { + let mut stmt = self + .conn + .prepare("SELECT 1 FROM favorite_awards WHERE award_id = ?1") + .map_err(|e| format!("Failed to prepare query: {}", e))?; + + let exists = stmt + .exists(params![award_id]) + .map_err(|e| format!("Failed to check favorite: {}", e))?; + + Ok(exists) + } + + pub fn add_favorite_award(&self, award_id: &str) -> Result<(), String> { + self.conn + .execute( + "INSERT OR IGNORE INTO favorite_awards (award_id) VALUES (?1)", + params![award_id], + ) + .map_err(|e| format!("Failed to add favorite award: {}", e))?; + Ok(()) + } + + pub fn remove_favorite_award(&self, award_id: &str) -> Result<(), String> { + self.conn + .execute( + "DELETE FROM favorite_awards WHERE award_id = ?1", + params![award_id], + ) + .map_err(|e| format!("Failed to remove favorite award: {}", e))?; + Ok(()) + } + + pub fn sync_favorite_awards(&self, award_ids: &[String]) -> Result<(), String> { + self.conn + .execute("DELETE FROM favorite_awards", []) + .map_err(|e| format!("Failed to clear favorite awards: {}", e))?; + + for award_id in award_ids { + self.conn + .execute( + "INSERT INTO favorite_awards (award_id) VALUES (?1)", + params![award_id], + ) + .map_err(|e| format!("Failed to insert favorite award: {}", e))?; + } + Ok(()) + } + // ============ Clear all (for logout) ============ pub fn clear_all(&self) -> Result<(), String> { @@ -397,6 +477,9 @@ impl FavoritesCacheStore { self.conn .execute("DELETE FROM favorite_labels", []) .map_err(|e| format!("Failed to clear favorite labels: {}", e))?; + self.conn + .execute("DELETE FROM favorite_awards", []) + .map_err(|e| format!("Failed to clear favorite awards: {}", e))?; Ok(()) } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 17616db66..6f6653bbd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1694,6 +1694,10 @@ pub fn run() { commands_v2::v2_sync_cached_favorite_labels, commands_v2::v2_cache_favorite_label, commands_v2::v2_uncache_favorite_label, + commands_v2::v2_get_cached_favorite_awards, + commands_v2::v2_sync_cached_favorite_awards, + commands_v2::v2_cache_favorite_award, + commands_v2::v2_uncache_favorite_award, commands_v2::v2_share_track_songlink, commands_v2::v2_share_album_songlink, commands_v2::v2_library_backfill_downloads, diff --git a/src/lib/components/views/AwardView.svelte b/src/lib/components/views/AwardView.svelte index 98d0ff4c7..cc442ceba 100644 --- a/src/lib/components/views/AwardView.svelte +++ b/src/lib/components/views/AwardView.svelte @@ -5,14 +5,19 @@ * hero info and uses the embedded releases arrays for the album * grids. Matches LabelView's overall structure. */ - import { onMount } from 'svelte'; + import { onMount, onDestroy } from 'svelte'; import { invoke } from '@tauri-apps/api/core'; import { t } from '$lib/i18n'; - import { ArrowLeft, Award as AwardIcon, LoaderCircle } from 'lucide-svelte'; + import { ArrowLeft, Award as AwardIcon, Heart, LoaderCircle } from 'lucide-svelte'; import AlbumCard from '../AlbumCard.svelte'; - import HorizontalScrollRow from '../HorizontalScrollRow.svelte'; import type { AwardPageData, QobuzAlbum } from '$lib/types'; - import { formatQuality } from '$lib/adapters/qobuzAdapters'; + import { formatQuality, getQobuzImage } from '$lib/adapters/qobuzAdapters'; + import { + subscribe as subscribeAwardFavorites, + isAwardFavorite, + isAwardToggling, + toggleAwardFavorite, + } from '$lib/stores/awardFavoritesStore'; interface AlbumCardData { id: string; @@ -25,11 +30,6 @@ releaseDate?: string; } - interface ReleasesSection { - id: string; - items: AlbumCardData[]; - } - interface Props { awardId: string; awardName?: string; @@ -68,82 +68,109 @@ onArtistClick, }: Props = $props(); + const PAGE_SIZE = 50; + let page = $state(null); - let sections = $state([]); + let albums = $state([]); + let offset = $state(0); + let totalEstimate = $state(null); + let hasMore = $state(true); let loading = $state(true); + let loadingMore = $state(false); let error = $state(null); let heroImageFailed = $state(false); - function toAlbumCard(item: Record): AlbumCardData | null { - if (!item || typeof item !== 'object') return null; - const id = (item.id as string) ?? ''; - if (!id) return null; - const image = item.image as { small?: string; thumbnail?: string; large?: string } | undefined; - const artists = (item.artists as { id: number; name: string }[]) ?? []; - const artist = (item.artist as { id?: number; name?: string }) ?? artists[0] ?? {}; - const genre = item.genre as { name?: string } | undefined; - const audioInfo = item.audio_info as { maximum_bit_depth?: number; maximum_sampling_rate?: number } | undefined; - const dates = item.dates as { original?: string } | undefined; - const bitDepth = audioInfo?.maximum_bit_depth ?? 16; - const quality = formatQuality( - bitDepth > 16, - audioInfo?.maximum_bit_depth, - audioInfo?.maximum_sampling_rate - ); + /** Adapts a QobuzAlbum (shape returned by /award/getAlbums) into + * what AlbumCard consumes. */ + function qobuzToCard(album: QobuzAlbum): AlbumCardData { + const hires = (album.maximum_bit_depth ?? 16) > 16; return { - id: String(id), - artwork: image?.large || image?.small || image?.thumbnail || '', - title: (item.title as string) ?? '', - artist: artist?.name ?? 'Unknown Artist', - artistId: artist?.id, - genre: genre?.name ?? '', - quality, - releaseDate: dates?.original, + id: album.id, + artwork: getQobuzImage(album.image), + title: album.title, + artist: album.artist?.name ?? 'Unknown Artist', + artistId: album.artist?.id, + genre: album.genre?.name ?? '', + quality: formatQuality(hires, album.maximum_bit_depth, album.maximum_sampling_rate), + releaseDate: album.release_date_original, }; } - async function loadPage() { - loading = true; - error = null; + async function loadHero() { try { + // /award/page is user-scoped (only returns releases from awards + // the user has favorited) so we only use it to hydrate the hero + // — name, image, magazine. The album grid below fills from + // /award/getAlbums which is the public catalog listing. const data = await invoke('v2_get_award_page', { awardId }); page = data; - - const builtSections: ReleasesSection[] = []; - for (const container of data.releases ?? []) { - const items = (container.data?.items ?? []) - .map(toAlbumCard) - .filter((a): a is AlbumCardData => a !== null); - if (items.length > 0) { - builtSections.push({ - id: container.id ?? 'releases', - items, - }); - } - } - sections = builtSections; } catch (err) { - console.error('[AwardView] failed to load:', err); - error = String(err); - } finally { - loading = false; + console.warn('[AwardView] /award/page failed, falling back to passed name:', err); } } + async function loadAlbums(reset: boolean) { + if (reset) { + offset = 0; + albums = []; + hasMore = true; + totalEstimate = null; + loading = true; + } else { + loadingMore = true; + } + try { + const result = await invoke<{ items: QobuzAlbum[]; total: number; offset: number; limit: number }>( + 'v2_get_award_albums', + { awardId, limit: PAGE_SIZE, offset } + ); + const mapped = (result.items ?? []).map(qobuzToCard); + albums = reset ? mapped : [...albums, ...mapped]; + offset = albums.length; + totalEstimate = result.total; + hasMore = mapped.length >= PAGE_SIZE && offset < result.total; + } catch (err) { + console.error('[AwardView] failed to load albums:', err); + error = String(err); + } finally { + loading = false; + loadingMore = false; + } + } + + function handleLoadMore() { + if (!loading && !loadingMore && hasMore) loadAlbums(false); + } + + // Subscribe to the favorites store so the Follow button's visual + // state updates in real time (and from other views that may mutate it). + let awardFavoritesVersion = $state(0); + const unsubAwardFavorites = subscribeAwardFavorites(() => { + awardFavoritesVersion += 1; + }); + onDestroy(() => unsubAwardFavorites()); + + const awardIsFavorite = $derived.by(() => { + void awardFavoritesVersion; + return isAwardFavorite(awardId); + }); + const awardIsToggling = $derived.by(() => { + void awardFavoritesVersion; + return isAwardToggling(awardId); + }); + + async function handleToggleAwardFavorite() { + await toggleAwardFavorite(awardId); + } + onMount(() => { - loadPage(); + loadHero(); + loadAlbums(true); }); const displayName = $derived(page?.name ?? awardName ?? ''); const magazineName = $derived(page?.magazine?.name ?? ''); const heroImage = $derived(page?.image || page?.magazine?.image || ''); - - function getContainerTitle(id: string): string { - const key = `award.section.${id}`; - const translated = $t(key); - if (translated && !translated.startsWith('award.section.')) return translated; - return $t('award.section.releases'); - }
@@ -175,10 +202,34 @@ {#if magazineName}
{magazineName}
{/if} + +
+ +
+
+

{$t('award.section.releases')}

+ {#if totalEstimate} + {totalEstimate} + {/if} +
+ {#if loading}
@@ -188,48 +239,51 @@

{$t('favorites.failedLoadFavorites')}

{error}

- +
- {:else if sections.length === 0} + {:else if albums.length === 0}

{$t('award.empty')}

{:else} - {#each sections as section (section.id)} -
- - {#snippet children()} - {#each section.items as album (album.id)} - onAlbumPlay(album.id) : undefined} - onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined} - onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined} - onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined} - onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined} - onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined} - onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined} - isAlbumFullyDownloaded={isAlbumDownloaded?.(album.id) ?? false} - onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined} - onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined} - {downloadStateVersion} - onclick={() => onAlbumClick?.(album.id)} - /> - {/each} -
- {/snippet} -
-
- {/each} +
+ {#each albums as album (album.id)} + onAlbumPlay(album.id) : undefined} + onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined} + onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined} + onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined} + onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined} + onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined} + onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined} + isAlbumFullyDownloaded={isAlbumDownloaded?.(album.id) ?? false} + onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined} + onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined} + {downloadStateVersion} + onclick={() => onAlbumClick?.(album.id)} + /> + {/each} +
+ {#if hasMore} +
+ +
+ {/if} {/if}
@@ -288,12 +342,69 @@ font-size: 14px; color: var(--text-secondary); line-height: 1.4; } - .content { + /* Follow button — matches LabelView / ArtistDetailView visual */ + .award-actions { display: flex; - flex-direction: column; - gap: 48px; + gap: 12px; + margin-top: 20px; } - .rail { display: flex; flex-direction: column; } + .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, 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; + } + + .content { display: flex; flex-direction: column; gap: 20px; } + .section-header { + display: flex; align-items: baseline; gap: 12px; + } + .section-title { + font-size: 20px; font-weight: 600; color: var(--text-primary); margin: 0; + } + .section-count { + font-size: 12px; color: var(--text-muted); + } + .album-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 24px 16px; + } + .load-more-wrapper { + display: flex; justify-content: center; margin-top: 8px; + } + .load-more-btn { + display: inline-flex; align-items: center; gap: 8px; + padding: 8px 20px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: 6px; + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + transition: background-color 150ms ease; + } + .load-more-btn:hover:not(:disabled) { background: var(--bg-secondary); } + .load-more-btn:disabled { opacity: 0.6; cursor: wait; } .loading, .error, diff --git a/src/lib/i18n/locales/de.json b/src/lib/i18n/locales/de.json index 40b4ced5c..dddcb6447 100644 --- a/src/lib/i18n/locales/de.json +++ b/src/lib/i18n/locales/de.json @@ -2638,6 +2638,8 @@ "releases": "Gefeierte Veröffentlichungen", "new-releases": "Neuerscheinungen", "classics": "Klassiker" - } + }, + "follow": "Auszeichnung folgen", + "unfollow": "Nicht mehr folgen" } } diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json index 726f7130b..63c858d6f 100644 --- a/src/lib/i18n/locales/en.json +++ b/src/lib/i18n/locales/en.json @@ -2638,6 +2638,8 @@ "releases": "Acclaimed Releases", "new-releases": "New Releases", "classics": "Classics" - } + }, + "follow": "Follow award", + "unfollow": "Unfollow award" } } diff --git a/src/lib/i18n/locales/es.json b/src/lib/i18n/locales/es.json index 86046bc9e..7e2dcfb91 100644 --- a/src/lib/i18n/locales/es.json +++ b/src/lib/i18n/locales/es.json @@ -2638,6 +2638,8 @@ "releases": "Lanzamientos aclamados", "new-releases": "Novedades", "classics": "Clásicos" - } + }, + "follow": "Seguir premio", + "unfollow": "Dejar de seguir" } } diff --git a/src/lib/i18n/locales/fr.json b/src/lib/i18n/locales/fr.json index d32da8086..e763b6501 100644 --- a/src/lib/i18n/locales/fr.json +++ b/src/lib/i18n/locales/fr.json @@ -2638,6 +2638,8 @@ "releases": "Sorties saluées", "new-releases": "Nouveautés", "classics": "Classiques" - } + }, + "follow": "Suivre la récompense", + "unfollow": "Ne plus suivre" } } diff --git a/src/lib/i18n/locales/pt.json b/src/lib/i18n/locales/pt.json index 5cc40abff..fa38cd33f 100644 --- a/src/lib/i18n/locales/pt.json +++ b/src/lib/i18n/locales/pt.json @@ -2638,6 +2638,8 @@ "releases": "Lançamentos aclamados", "new-releases": "Novidades", "classics": "Clássicos" - } + }, + "follow": "Seguir prêmio", + "unfollow": "Deixar de seguir" } } diff --git a/src/lib/stores/awardFavoritesStore.ts b/src/lib/stores/awardFavoritesStore.ts new file mode 100644 index 000000000..fca30657d --- /dev/null +++ b/src/lib/stores/awardFavoritesStore.ts @@ -0,0 +1,155 @@ +/** + * Award Favorites Store + * + * Centralized store for tracking favorite (followed) awards. Clone of + * labelFavoritesStore — same SQLite-backed cache, same sync strategy, + * same toggle flow. Award ids come through as strings (the Qobuz + * mobile API stringifies them on favorite/create?award_ids=). + * + * This is what feeds the "Awards" tab in Release Watch — the set of + * awards the user is following is used to fetch + * /favorite/getNewReleases?type=awards. + */ + +import { invoke } from '@tauri-apps/api/core'; +import { skipIfRemote } from '$lib/services/commandRouter'; + +let favoriteAwardIds = new Set(); +let togglingAwardIds = new Set(); +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 isAwardFavorite(awardId: string | number): boolean { + return favoriteAwardIds.has(String(awardId)); +} + +export function isAwardToggling(awardId: string | number): boolean { + return togglingAwardIds.has(String(awardId)); +} + +export function getFavoriteAwardIds(): string[] { + return Array.from(favoriteAwardIds); +} + +export function isFavoritesLoaded(): boolean { + return isLoaded; +} + +export async function loadAwardFavorites(): Promise { + if (skipIfRemote()) return; + if (isLoading) return; + + isLoading = true; + try { + try { + const cachedIds = await invoke('v2_get_cached_favorite_awards'); + if (cachedIds.length > 0) { + favoriteAwardIds = new Set(cachedIds); + isLoaded = true; + notifyListeners(); + console.log(`[AwardFavorites] Loaded ${cachedIds.length} awards from local cache`); + } + } catch (cacheErr) { + console.debug('[AwardFavorites] No local cache available:', cacheErr); + } + + await syncFromApi(); + } catch (err) { + console.error('[AwardFavorites] Failed to load favorites:', err); + if (!isLoaded) { + favoriteAwardIds = new Set(); + isLoaded = true; + notifyListeners(); + } + } finally { + isLoading = false; + } +} + +export async function syncFromApi(): Promise { + if (skipIfRemote()) return; + try { + const allAwardIds: string[] = []; + let offset = 0; + const limit = 500; + + while (true) { + const result = await invoke<{ awards?: { items: Array<{ id: string | number }>; total?: number } }>( + 'v2_get_favorites', + { favType: 'awards', limit, offset } + ); + + const items = result.awards?.items ?? []; + if (!items.length) break; + + allAwardIds.push(...items.map(item => String(item.id))); + offset += items.length; + + if (items.length < limit) break; + if (result.awards?.total && offset >= result.awards.total) break; + } + + await invoke('v2_sync_cached_favorite_awards', { awardIds: allAwardIds }); + + favoriteAwardIds = new Set(allAwardIds); + isLoaded = true; + notifyListeners(); + + console.log(`[AwardFavorites] Synced ${allAwardIds.length} awards from API`); + } catch (err) { + console.error('[AwardFavorites] Failed to sync from API:', err); + throw err; + } +} + +export async function toggleAwardFavorite(awardId: string | number): Promise { + const key = String(awardId); + if (skipIfRemote()) return favoriteAwardIds.has(key); + if (togglingAwardIds.has(key)) return favoriteAwardIds.has(key); + + const wasFavorite = favoriteAwardIds.has(key); + const newState = !wasFavorite; + + togglingAwardIds.add(key); + notifyListeners(); + + try { + if (newState) { + await invoke('v2_add_favorite', { favType: 'award', itemId: key }); + await invoke('v2_cache_favorite_award', { awardId: key }); + favoriteAwardIds.add(key); + } else { + await invoke('v2_remove_favorite', { favType: 'award', itemId: key }); + await invoke('v2_uncache_favorite_award', { awardId: key }); + favoriteAwardIds.delete(key); + } + return newState; + } catch (err) { + console.error('[AwardFavorites] Failed to toggle favorite:', err); + return wasFavorite; + } finally { + togglingAwardIds.delete(key); + notifyListeners(); + } +} + +export async function syncCache(awardIds: string[]): Promise { + if (skipIfRemote()) return; + try { + await invoke('v2_sync_cached_favorite_awards', { awardIds }); + favoriteAwardIds = new Set(awardIds); + notifyListeners(); + } catch (err) { + console.error('[AwardFavorites] Failed to sync cache:', err); + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 13a395879..d2d6a0e44 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -154,6 +154,7 @@ import { loadAlbumFavorites } from '$lib/stores/albumFavoritesStore'; import { loadArtistFavorites } from '$lib/stores/artistFavoritesStore'; import { loadLabelFavorites } from '$lib/stores/labelFavoritesStore'; + import { loadAwardFavorites } from '$lib/stores/awardFavoritesStore'; import { getDefaultFavoritesTab } from '$lib/utils/favorites'; import { platform } from '$lib/utils/platform'; import type { FavoritesPreferences, ResolvedMusician } from '$lib/types'; @@ -3679,6 +3680,7 @@ loadAlbumFavorites(); // Album favorites loadArtistFavorites(); // Artist favorites loadLabelFavorites(); // Label favorites + loadAwardFavorites(); // Award favorites // Refresh offline status now that we're logged in refreshOfflineStatus().catch(err => console.debug('[Offline] Status refresh deferred:', err));