diff --git a/crates/qbz-library/src/database.rs b/crates/qbz-library/src/database.rs index afa9efece..52d05f33a 100644 --- a/crates/qbz-library/src/database.rs +++ b/crates/qbz-library/src/database.rs @@ -191,6 +191,17 @@ impl LibraryDatabase { ); CREATE INDEX IF NOT EXISTS idx_artist_images_fetched ON artist_images(fetched_at); + + -- Downloaded purchases registry (permanent — user owns these files) + CREATE TABLE IF NOT EXISTS downloaded_purchases ( + track_id INTEGER PRIMARY KEY, + album_id TEXT, + file_path TEXT NOT NULL, + downloaded_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_downloaded_purchases_album + ON downloaded_purchases(album_id); "#, ) .map_err(|e| LibraryError::Database(format!("Failed to create schema: {}", e)))?; @@ -3726,4 +3737,57 @@ impl LibraryDatabase { .collect::, _>>() .map_err(|e| LibraryError::Database(format!("Failed to collect playlists: {}", e))) } + + // ── Downloaded Purchases Registry ── + + /// Record a track as downloaded on this computer. + pub fn mark_purchase_downloaded( + &self, + track_id: i64, + album_id: Option<&str>, + file_path: &str, + ) -> Result<(), LibraryError> { + self.conn + .execute( + "INSERT OR REPLACE INTO downloaded_purchases (track_id, album_id, file_path, downloaded_at) + VALUES (?1, ?2, ?3, datetime('now'))", + rusqlite::params![track_id, album_id, file_path], + ) + .map_err(|e| { + LibraryError::Database(format!("Failed to mark purchase downloaded: {}", e)) + })?; + Ok(()) + } + + /// Remove a downloaded purchase record (e.g. user deleted the file). + pub fn remove_downloaded_purchase(&self, track_id: i64) -> Result<(), LibraryError> { + self.conn + .execute( + "DELETE FROM downloaded_purchases WHERE track_id = ?1", + [track_id], + ) + .map_err(|e| { + LibraryError::Database(format!("Failed to remove downloaded purchase: {}", e)) + })?; + Ok(()) + } + + /// Get all downloaded track IDs for fast lookup. + pub fn get_downloaded_purchase_track_ids(&self) -> Result, LibraryError> { + let mut stmt = self + .conn + .prepare("SELECT track_id FROM downloaded_purchases") + .map_err(|e| { + LibraryError::Database(format!("Failed to prepare statement: {}", e)) + })?; + + let ids = stmt + .query_map([], |row| row.get::<_, i64>(0)) + .map_err(|e| { + LibraryError::Database(format!("Failed to query downloaded purchases: {}", e)) + })?; + + ids.collect::, _>>() + .map_err(|e| LibraryError::Database(format!("Failed to collect ids: {}", e))) + } } diff --git a/src-tauri/src/commands_v2.rs b/src-tauri/src/commands_v2.rs index 33697d0ab..67cd1702f 100644 --- a/src-tauri/src/commands_v2.rs +++ b/src-tauri/src/commands_v2.rs @@ -7263,3 +7263,41 @@ pub fn v2_restart_app(app: tauri::AppHandle) { log::info!("[V2] App restart requested by user"); app.restart(); } + +// ── Downloaded Purchases Registry ── + +#[tauri::command] +#[allow(non_snake_case)] +pub async fn v2_purchases_mark_downloaded( + trackId: i64, + albumId: Option, + filePath: String, + library_state: State<'_, LibraryState>, +) -> Result<(), String> { + let guard = library_state.db.lock().await; + let db = guard.as_ref().ok_or("No active session - please log in")?; + db.mark_purchase_downloaded(trackId, albumId.as_deref(), &filePath) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +#[allow(non_snake_case)] +pub async fn v2_purchases_remove_downloaded( + trackId: i64, + library_state: State<'_, LibraryState>, +) -> Result<(), String> { + let guard = library_state.db.lock().await; + let db = guard.as_ref().ok_or("No active session - please log in")?; + db.remove_downloaded_purchase(trackId) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn v2_purchases_get_downloaded_track_ids( + library_state: State<'_, LibraryState>, +) -> Result, String> { + let guard = library_state.db.lock().await; + let db = guard.as_ref().ok_or("No active session - please log in")?; + db.get_downloaded_purchase_track_ids() + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b9d4b1e36..2c2cafad4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -996,6 +996,9 @@ 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_purchases_mark_downloaded, + commands_v2::v2_purchases_remove_downloaded, + commands_v2::v2_purchases_get_downloaded_track_ids, commands_v2::v2_resolve_qobuz_link, commands_v2::v2_check_qobuzapp_handler, commands_v2::v2_register_qobuzapp_handler, diff --git a/src/lib/components/views/PurchaseAlbumDetailView.svelte b/src/lib/components/views/PurchaseAlbumDetailView.svelte index 484e1834d..f227a50ee 100644 --- a/src/lib/components/views/PurchaseAlbumDetailView.svelte +++ b/src/lib/components/views/PurchaseAlbumDetailView.svelte @@ -6,7 +6,7 @@ import QualityBadge from '../QualityBadge.svelte'; import Dropdown from '../Dropdown.svelte'; import ViewTransition from '../ViewTransition.svelte'; - import { getAlbumDetail, getFormats, downloadAlbum, downloadTrack } from '$lib/services/purchases'; + import { getAlbumDetail, getFormats, downloadAlbum, downloadTrack, markTrackDownloaded } from '$lib/services/purchases'; import { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters'; import type { PurchasedAlbum, PurchasedTrack, PurchaseFormatOption, PurchaseDownloadProgress } from '$lib/types/purchases'; @@ -108,6 +108,7 @@ try { await downloadTrack(track.id, selectedFormatId, destination); downloadStatuses.set(track.id, 'complete'); + await markTrackDownloaded(track.id, album.id, destination).catch(() => {}); } catch { downloadStatuses.set(track.id, 'failed'); } @@ -124,6 +125,7 @@ try { await downloadTrack(action, selectedFormatId, destination); downloadStatuses.set(action, 'complete'); + await markTrackDownloaded(action, album.id, destination).catch(() => {}); } catch { downloadStatuses.set(action, 'failed'); } diff --git a/src/lib/components/views/PurchasesView.svelte b/src/lib/components/views/PurchasesView.svelte index d0f9cfa16..80c05b0a6 100644 --- a/src/lib/components/views/PurchasesView.svelte +++ b/src/lib/components/views/PurchasesView.svelte @@ -8,13 +8,13 @@ } from 'lucide-svelte'; import AlbumCard from '../AlbumCard.svelte'; import QualityBadge from '../QualityBadge.svelte'; - import { getPurchases, searchPurchases } from '$lib/services/purchases'; + import { getPurchases, searchPurchases, getDownloadedTrackIds } from '$lib/services/purchases'; import { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters'; import { getHideUnavailable, setHideUnavailable, - getHiResOnly, setHiResOnly, getHideDownloaded, setHideDownloaded, } from '$lib/stores/purchasesStore'; + import { getUserItem, setUserItem } from '$lib/utils/userStorage'; import type { PurchasedAlbum, PurchasedTrack, PurchaseResponse } from '$lib/types/purchases'; type PurchasesTab = 'albums' | 'tracks'; @@ -58,6 +58,7 @@ // Data let albums = $state([]); let tracks = $state([]); + let downloadedTrackIds = $state>(new Set()); let loading = $state(true); let error = $state(null); @@ -73,8 +74,9 @@ // Albums: filter panel (LocalLibraryView pattern) let showFilterPanel = $state(false); let filterPanelRef = $state(null); + type QualityFilter = 'all' | 'hires' | 'cd' | 'lossy'; let filterHideUnavailable = $state(getHideUnavailable()); - let filterHiResOnly = $state(getHiResOnly()); + let filterQuality = $state((getUserItem('qbz-purchases-quality-filter') as QualityFilter) || 'all'); let filterHideDownloaded = $state(getHideDownloaded()); // Tracks: grouping @@ -84,7 +86,7 @@ // Persist filter changes $effect(() => { setHideUnavailable(filterHideUnavailable); }); - $effect(() => { setHiResOnly(filterHiResOnly); }); + $effect(() => { setUserItem('qbz-purchases-quality-filter', filterQuality); }); $effect(() => { setHideDownloaded(filterHideDownloaded); }); const albumSortOptions = [ @@ -94,17 +96,17 @@ { value: 'quality' as SortBy, label: 'Quality' }, ]; - const hasActiveFilters = $derived(filterHideUnavailable || filterHiResOnly || filterHideDownloaded); + const hasActiveFilters = $derived(filterHideUnavailable || filterQuality !== 'all' || filterHideDownloaded); const activeFilterCount = $derived( - (filterHideUnavailable ? 1 : 0) + (filterHiResOnly ? 1 : 0) + (filterHideDownloaded ? 1 : 0) + (filterHideUnavailable ? 1 : 0) + (filterQuality !== 'all' ? 1 : 0) + (filterHideDownloaded ? 1 : 0) ); function clearAllFilters() { filterHideUnavailable = false; - filterHiResOnly = false; + filterQuality = 'all'; filterHideDownloaded = false; setHideUnavailable(false); - setHiResOnly(false); + setUserItem('qbz-purchases-quality-filter', 'all'); setHideDownloaded(false); } @@ -138,10 +140,18 @@ // ── Album filtering & sorting ── + function matchesQualityFilter(hires: boolean, bitDepth?: number, samplingRate?: number): boolean { + if (filterQuality === 'all') return true; + if (filterQuality === 'hires') return hires; + if (filterQuality === 'cd') return !hires && (bitDepth === 16 || (!bitDepth && !samplingRate)); + if (filterQuality === 'lossy') return !bitDepth || bitDepth < 16; + return true; + } + function applyAlbumFilters(list: PurchasedAlbum[]): PurchasedAlbum[] { let result = list; if (filterHideUnavailable) result = result.filter((a) => a.downloadable); - if (filterHiResOnly) result = result.filter((a) => a.hires); + if (filterQuality !== 'all') result = result.filter((a) => matchesQualityFilter(a.hires, a.maximum_bit_depth, a.maximum_sampling_rate)); if (filterHideDownloaded) result = result.filter((a) => !a.downloaded); return result; } @@ -149,7 +159,7 @@ function applyTrackFilters(list: PurchasedTrack[]): PurchasedTrack[] { let result = list; if (filterHideDownloaded) result = result.filter((track) => !track.downloaded); - if (filterHiResOnly) result = result.filter((track) => track.hires); + if (filterQuality !== 'all') result = result.filter((track) => matchesQualityFilter(track.hires, track.maximum_bit_depth, track.maximum_sampling_rate)); return result; } @@ -227,13 +237,35 @@ // ── Data loading ── + function enrichWithDownloadStatus( + albumList: PurchasedAlbum[], + trackList: PurchasedTrack[], + dlIds: Set, + ): { albums: PurchasedAlbum[]; tracks: PurchasedTrack[] } { + const enrichedAlbums = albumList.map((album) => { + const albumTrackIds = album.tracks?.items?.map((track) => track.id) || []; + const allDownloaded = albumTrackIds.length > 0 && albumTrackIds.every((id) => dlIds.has(id)); + return { ...album, downloaded: allDownloaded }; + }); + const enrichedTracks = trackList.map((track) => ({ + ...track, + downloaded: dlIds.has(track.id), + })); + return { albums: enrichedAlbums, tracks: enrichedTracks }; + } + async function loadPurchases() { loading = true; error = null; try { - const response: PurchaseResponse = await getPurchases(); - albums = response.albums.items; - tracks = response.tracks.items; + const [response, dlIds] = await Promise.all([ + getPurchases(), + getDownloadedTrackIds().catch(() => new Set()), + ]); + downloadedTrackIds = dlIds; + const enriched = enrichWithDownloadStatus(response.albums.items, response.tracks.items, dlIds); + albums = enriched.albums; + tracks = enriched.tracks; } catch (err) { error = String(err); } finally { @@ -251,8 +283,9 @@ loading = true; try { const response = await searchPurchases(searchQuery.trim()); - albums = response.albums.items; - tracks = response.tracks.items; + const enriched = enrichWithDownloadStatus(response.albums.items, response.tracks.items, downloadedTrackIds); + albums = enriched.albums; + tracks = enriched.tracks; } catch (err) { error = String(err); } finally { @@ -437,11 +470,28 @@
+ + +
@@ -549,11 +599,28 @@
+ + +
@@ -1156,6 +1223,19 @@ transform: rotate(45deg) translateY(-1px); } + .filter-checkbox .checkmark.radio { + border-radius: 50%; + } + + .filter-checkbox input:checked + .checkmark.radio::after { + width: 6px; + height: 6px; + border: none; + background: white; + border-radius: 50%; + transform: none; + } + .filter-checkbox .label-text { font-size: 12px; color: var(--text-primary); @@ -1233,16 +1313,17 @@ .unavailable-overlay { position: absolute; - bottom: 36px; + top: 0; left: 0; right: 0; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; text-align: center; - padding: 4px 8px; - background: rgba(0, 0, 0, 0.75); + padding: 16px; color: var(--text-muted); font-size: 11px; - border-radius: 4px; - margin: 0 8px; } /* ── Albums list ── */ diff --git a/src/lib/services/purchases.ts b/src/lib/services/purchases.ts index 757f33e5a..d25bd06a0 100644 --- a/src/lib/services/purchases.ts +++ b/src/lib/services/purchases.ts @@ -91,3 +91,26 @@ export async function downloadTrack( destination }); } + +// ── Downloaded Purchases Registry (V2 backend, SQLite) ── + +export async function getDownloadedTrackIds(): Promise> { + const ids = await invoke('v2_purchases_get_downloaded_track_ids'); + return new Set(ids); +} + +export async function markTrackDownloaded( + trackId: number, + albumId: string | undefined, + filePath: string +): Promise { + return invoke('v2_purchases_mark_downloaded', { + trackId, + albumId: albumId ?? null, + filePath + }); +} + +export async function removeDownloadedTrack(trackId: number): Promise { + return invoke('v2_purchases_remove_downloaded', { trackId }); +} diff --git a/src/lib/stores/purchasesStore.ts b/src/lib/stores/purchasesStore.ts index 83466d26a..7c18138a2 100644 --- a/src/lib/stores/purchasesStore.ts +++ b/src/lib/stores/purchasesStore.ts @@ -26,19 +26,6 @@ export function setHideUnavailable(v: boolean): void { setUserItem(KEY_HIDE_UNAVAILABLE, String(v)); } -// Filter: hi-res only -const KEY_HIRES_ONLY = 'qbz-purchases-hires-only'; -let hiResOnly = getUserItem(KEY_HIRES_ONLY) === 'true'; - -export function getHiResOnly(): boolean { - return hiResOnly; -} - -export function setHiResOnly(v: boolean): void { - hiResOnly = v; - setUserItem(KEY_HIRES_ONLY, String(v)); -} - // Filter: hide downloaded const KEY_HIDE_DOWNLOADED = 'qbz-purchases-hide-downloaded'; let hideDownloaded = getUserItem(KEY_HIDE_DOWNLOADED) === 'true';