feat(award): use /award/getAlbums for the grid + follow award as entity

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
This commit is contained in:
vicrodh committed 2026-04-14 13:02:37 -06:00
1 parent e52e3e1fdc
commit a273174716
11 files changed
+546 -109

No files matched your search

+72
View File
@@ -427,3 +427,75 @@ pub async fn v2_uncache_favorite_label(
.remove_favorite_label(labelId) .remove_favorite_label(labelId)
.map_err(|e| RuntimeError::Internal(e)) .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<Vec<String>, 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<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
.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))
}
+83
View File
@@ -95,6 +95,18 @@ impl FavoritesCacheStore {
) )
.map_err(|e| format!("Failed to create favorite_labels table: {}", e))?; .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 }) Ok(Self { conn })
} }
@@ -382,6 +394,74 @@ impl FavoritesCacheStore {
Ok(()) Ok(())
} }
// ============ Award favorites ============
pub fn get_favorite_award_ids(&self) -> Result<Vec<String>, 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<bool, String> {
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) ============ // ============ Clear all (for logout) ============
pub fn clear_all(&self) -> Result<(), String> { pub fn clear_all(&self) -> Result<(), String> {
@@ -397,6 +477,9 @@ impl FavoritesCacheStore {
self.conn self.conn
.execute("DELETE FROM favorite_labels", []) .execute("DELETE FROM favorite_labels", [])
.map_err(|e| format!("Failed to clear favorite labels: {}", e))?; .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(()) Ok(())
} }
} }
+4
View File
@@ -1694,6 +1694,10 @@ pub fn run() {
commands_v2::v2_sync_cached_favorite_labels, commands_v2::v2_sync_cached_favorite_labels,
commands_v2::v2_cache_favorite_label, commands_v2::v2_cache_favorite_label,
commands_v2::v2_uncache_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_track_songlink,
commands_v2::v2_share_album_songlink, commands_v2::v2_share_album_songlink,
commands_v2::v2_library_backfill_downloads, commands_v2::v2_library_backfill_downloads,
+215 -104
View File
@@ -5,14 +5,19 @@
* hero info and uses the embedded releases arrays for the album * hero info and uses the embedded releases arrays for the album
* grids. Matches LabelView's overall structure. * grids. Matches LabelView's overall structure.
*/ */
import { onMount } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { t } from '$lib/i18n'; 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 AlbumCard from '../AlbumCard.svelte';
import HorizontalScrollRow from '../HorizontalScrollRow.svelte';
import type { AwardPageData, QobuzAlbum } from '$lib/types'; 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 { interface AlbumCardData {
id: string; id: string;
@@ -25,11 +30,6 @@
releaseDate?: string; releaseDate?: string;
} }
interface ReleasesSection {
id: string;
items: AlbumCardData[];
}
interface Props { interface Props {
awardId: string; awardId: string;
awardName?: string; awardName?: string;
@@ -68,82 +68,109 @@
onArtistClick, onArtistClick,
}: Props = $props(); }: Props = $props();
const PAGE_SIZE = 50;
let page = $state<AwardPageData | null>(null); let page = $state<AwardPageData | null>(null);
let sections = $state<ReleasesSection[]>([]); let albums = $state<AlbumCardData[]>([]);
let offset = $state(0);
let totalEstimate = $state<number | null>(null);
let hasMore = $state(true);
let loading = $state(true); let loading = $state(true);
let loadingMore = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let heroImageFailed = $state(false); let heroImageFailed = $state(false);
function toAlbumCard(item: Record<string, unknown>): AlbumCardData | null { /** Adapts a QobuzAlbum (shape returned by /award/getAlbums) into
if (!item || typeof item !== 'object') return null; * what AlbumCard consumes. */
const id = (item.id as string) ?? ''; function qobuzToCard(album: QobuzAlbum): AlbumCardData {
if (!id) return null; const hires = (album.maximum_bit_depth ?? 16) > 16;
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
);
return { return {
id: String(id), id: album.id,
artwork: image?.large || image?.small || image?.thumbnail || '', artwork: getQobuzImage(album.image),
title: (item.title as string) ?? '', title: album.title,
artist: artist?.name ?? 'Unknown Artist', artist: album.artist?.name ?? 'Unknown Artist',
artistId: artist?.id, artistId: album.artist?.id,
genre: genre?.name ?? '', genre: album.genre?.name ?? '',
quality, quality: formatQuality(hires, album.maximum_bit_depth, album.maximum_sampling_rate),
releaseDate: dates?.original, releaseDate: album.release_date_original,
}; };
} }
async function loadPage() { async function loadHero() {
loading = true;
error = null;
try { 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<AwardPageData>('v2_get_award_page', { awardId }); const data = await invoke<AwardPageData>('v2_get_award_page', { awardId });
page = data; 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) { } catch (err) {
console.error('[AwardView] failed to load:', err); console.warn('[AwardView] /award/page failed, falling back to passed name:', err);
error = String(err);
} finally {
loading = false;
} }
} }
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(() => { onMount(() => {
loadPage(); loadHero();
loadAlbums(true);
}); });
const displayName = $derived(page?.name ?? awardName ?? ''); const displayName = $derived(page?.name ?? awardName ?? '');
const magazineName = $derived(page?.magazine?.name ?? ''); const magazineName = $derived(page?.magazine?.name ?? '');
const heroImage = $derived(page?.image || page?.magazine?.image || ''); 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');
}
</script> </script>
<div class="award-detail-view"> <div class="award-detail-view">
@@ -175,10 +202,34 @@
{#if magazineName} {#if magazineName}
<div class="award-magazine">{magazineName}</div> <div class="award-magazine">{magazineName}</div>
{/if} {/if}
<div class="award-actions">
<button
class="favorite-btn"
class:is-favorite={awardIsFavorite}
onclick={handleToggleAwardFavorite}
disabled={awardIsToggling}
title={awardIsFavorite ? $t('award.unfollow') : $t('award.follow')}
aria-label={awardIsFavorite ? $t('award.unfollow') : $t('award.follow')}
>
{#if awardIsFavorite}
<Heart size={24} fill="var(--accent-primary)" color="var(--accent-primary)" />
{:else}
<Heart size={24} />
{/if}
</button>
</div>
</div> </div>
</header> </header>
<main class="content"> <main class="content">
<div class="section-header">
<h2 class="section-title">{$t('award.section.releases')}</h2>
{#if totalEstimate}
<span class="section-count">{totalEstimate}</span>
{/if}
</div>
{#if loading} {#if loading}
<div class="loading"> <div class="loading">
<LoaderCircle size={28} class="spinner" /> <LoaderCircle size={28} class="spinner" />
@@ -188,48 +239,51 @@
<div class="error"> <div class="error">
<p>{$t('favorites.failedLoadFavorites')}</p> <p>{$t('favorites.failedLoadFavorites')}</p>
<p class="error-detail">{error}</p> <p class="error-detail">{error}</p>
<button class="retry-btn" onclick={loadPage}>{$t('actions.retry')}</button> <button class="retry-btn" onclick={() => loadAlbums(true)}>{$t('actions.retry')}</button>
</div> </div>
{:else if sections.length === 0} {:else if albums.length === 0}
<div class="empty"> <div class="empty">
<p>{$t('award.empty')}</p> <p>{$t('award.empty')}</p>
</div> </div>
{:else} {:else}
{#each sections as section (section.id)} <div class="album-grid">
<section class="rail"> {#each albums as album (album.id)}
<HorizontalScrollRow title={getContainerTitle(section.id)}> <AlbumCard
{#snippet children()} albumId={album.id}
{#each section.items as album (album.id)} artwork={album.artwork}
<AlbumCard title={album.title}
albumId={album.id} artist={album.artist}
artwork={album.artwork} artistId={album.artistId}
title={album.title} onArtistClick={onArtistClick}
artist={album.artist} genre={album.genre}
artistId={album.artistId} releaseDate={album.releaseDate}
onArtistClick={onArtistClick} size="large"
genre={album.genre} quality={album.quality}
releaseDate={album.releaseDate} onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined}
size="large" onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined}
quality={album.quality} onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined}
onPlay={onAlbumPlay ? () => onAlbumPlay(album.id) : undefined} onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined}
onPlayNext={onAlbumPlayNext ? () => onAlbumPlayNext(album.id) : undefined} onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined}
onPlayLater={onAlbumPlayLater ? () => onAlbumPlayLater(album.id) : undefined} onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined}
onAddAlbumToPlaylist={onAddAlbumToPlaylist ? () => onAddAlbumToPlaylist(album.id) : undefined} onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined}
onShareQobuz={onAlbumShareQobuz ? () => onAlbumShareQobuz(album.id) : undefined} isAlbumFullyDownloaded={isAlbumDownloaded?.(album.id) ?? false}
onShareSonglink={onAlbumShareSonglink ? () => onAlbumShareSonglink(album.id) : undefined} onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined}
onDownload={onAlbumDownload ? () => onAlbumDownload(album.id) : undefined} onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined}
isAlbumFullyDownloaded={isAlbumDownloaded?.(album.id) ?? false} {downloadStateVersion}
onOpenContainingFolder={onOpenAlbumFolder ? () => onOpenAlbumFolder(album.id) : undefined} onclick={() => onAlbumClick?.(album.id)}
onReDownloadAlbum={onReDownloadAlbum ? () => onReDownloadAlbum(album.id) : undefined} />
{downloadStateVersion} {/each}
onclick={() => onAlbumClick?.(album.id)} </div>
/> {#if hasMore}
{/each} <div class="load-more-wrapper">
<div class="spacer"></div> <button class="load-more-btn" onclick={handleLoadMore} disabled={loadingMore}>
{/snippet} {#if loadingMore}
</HorizontalScrollRow> <LoaderCircle size={16} class="spinner" />
</section> {/if}
{/each} <span>{loadingMore ? $t('album.loading') : $t('home.seeMore')}</span>
</button>
</div>
{/if}
{/if} {/if}
</main> </main>
</div> </div>
@@ -288,12 +342,69 @@
font-size: 14px; color: var(--text-secondary); line-height: 1.4; font-size: 14px; color: var(--text-secondary); line-height: 1.4;
} }
.content { /* Follow button — matches LabelView / ArtistDetailView visual */
.award-actions {
display: flex; display: flex;
flex-direction: column; gap: 12px;
gap: 48px; 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, .loading,
.error, .error,
+3 -1
View File
@@ -2638,6 +2638,8 @@
"releases": "Gefeierte Veröffentlichungen", "releases": "Gefeierte Veröffentlichungen",
"new-releases": "Neuerscheinungen", "new-releases": "Neuerscheinungen",
"classics": "Klassiker" "classics": "Klassiker"
} },
"follow": "Auszeichnung folgen",
"unfollow": "Nicht mehr folgen"
} }
} }
+3 -1
View File
@@ -2638,6 +2638,8 @@
"releases": "Acclaimed Releases", "releases": "Acclaimed Releases",
"new-releases": "New Releases", "new-releases": "New Releases",
"classics": "Classics" "classics": "Classics"
} },
"follow": "Follow award",
"unfollow": "Unfollow award"
} }
} }
+3 -1
View File
@@ -2638,6 +2638,8 @@
"releases": "Lanzamientos aclamados", "releases": "Lanzamientos aclamados",
"new-releases": "Novedades", "new-releases": "Novedades",
"classics": "Clásicos" "classics": "Clásicos"
} },
"follow": "Seguir premio",
"unfollow": "Dejar de seguir"
} }
} }
+3 -1
View File
@@ -2638,6 +2638,8 @@
"releases": "Sorties saluées", "releases": "Sorties saluées",
"new-releases": "Nouveautés", "new-releases": "Nouveautés",
"classics": "Classiques" "classics": "Classiques"
} },
"follow": "Suivre la récompense",
"unfollow": "Ne plus suivre"
} }
} }
+3 -1
View File
@@ -2638,6 +2638,8 @@
"releases": "Lançamentos aclamados", "releases": "Lançamentos aclamados",
"new-releases": "Novidades", "new-releases": "Novidades",
"classics": "Clássicos" "classics": "Clássicos"
} },
"follow": "Seguir prêmio",
"unfollow": "Deixar de seguir"
} }
} }
+155
View File
@@ -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<string>();
let togglingAwardIds = new Set<string>();
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<void> {
if (skipIfRemote()) return;
if (isLoading) return;
isLoading = true;
try {
try {
const cachedIds = await invoke<string[]>('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<void> {
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<boolean> {
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<void> {
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);
}
}
+2
View File
@@ -154,6 +154,7 @@
import { loadAlbumFavorites } from '$lib/stores/albumFavoritesStore'; import { loadAlbumFavorites } from '$lib/stores/albumFavoritesStore';
import { loadArtistFavorites } from '$lib/stores/artistFavoritesStore'; import { loadArtistFavorites } from '$lib/stores/artistFavoritesStore';
import { loadLabelFavorites } from '$lib/stores/labelFavoritesStore'; import { loadLabelFavorites } from '$lib/stores/labelFavoritesStore';
import { loadAwardFavorites } from '$lib/stores/awardFavoritesStore';
import { getDefaultFavoritesTab } from '$lib/utils/favorites'; import { getDefaultFavoritesTab } from '$lib/utils/favorites';
import { platform } from '$lib/utils/platform'; import { platform } from '$lib/utils/platform';
import type { FavoritesPreferences, ResolvedMusician } from '$lib/types'; import type { FavoritesPreferences, ResolvedMusician } from '$lib/types';
@@ -3679,6 +3680,7 @@
loadAlbumFavorites(); // Album favorites loadAlbumFavorites(); // Album favorites
loadArtistFavorites(); // Artist favorites loadArtistFavorites(); // Artist favorites
loadLabelFavorites(); // Label favorites loadLabelFavorites(); // Label favorites
loadAwardFavorites(); // Award favorites
// Refresh offline status now that we're logged in // Refresh offline status now that we're logged in
refreshOfflineStatus().catch(err => console.debug('[Offline] Status refresh deferred:', err)); refreshOfflineStatus().catch(err => console.debug('[Offline] Status refresh deferred:', err));