feat: add downloaded purchases registry in SQLite backend with V2 commands

- downloaded_purchases table in library DB (permanent, per-user)
- V2 commands: mark_downloaded, remove_downloaded, get_downloaded_track_ids
- Frontend enriches purchase data with download status from backend
- Grid overlay for unavailable albums centered on artwork
- Quality filter expanded: All / Hi-Res / CD / Lossy (radio group)
- Album download status derived from track-level registry
This commit is contained in:
vicrodh committed 2026-02-17 18:57:24 -06:00
1 parent eb6830430e
commit 42915197e3
7 files changed
+236 -38

No files matched your search

+64
View File
@@ -191,6 +191,17 @@ impl LibraryDatabase {
); );
CREATE INDEX IF NOT EXISTS idx_artist_images_fetched ON artist_images(fetched_at); 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)))?; .map_err(|e| LibraryError::Database(format!("Failed to create schema: {}", e)))?;
@@ -3726,4 +3737,57 @@ impl LibraryDatabase {
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
.map_err(|e| LibraryError::Database(format!("Failed to collect playlists: {}", e))) .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<Vec<i64>, 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::<Result<Vec<_>, _>>()
.map_err(|e| LibraryError::Database(format!("Failed to collect ids: {}", e)))
}
} }
+38
View File
@@ -7263,3 +7263,41 @@ pub fn v2_restart_app(app: tauri::AppHandle) {
log::info!("[V2] App restart requested by user"); log::info!("[V2] App restart requested by user");
app.restart(); app.restart();
} }
// ── Downloaded Purchases Registry ──
#[tauri::command]
#[allow(non_snake_case)]
pub async fn v2_purchases_mark_downloaded(
trackId: i64,
albumId: Option<String>,
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<Vec<i64>, 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())
}
+3
View File
@@ -996,6 +996,9 @@ pub fn run() {
commands_v2::v2_sync_cached_favorite_artists, commands_v2::v2_sync_cached_favorite_artists,
commands_v2::v2_cache_favorite_artist, commands_v2::v2_cache_favorite_artist,
commands_v2::v2_uncache_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_resolve_qobuz_link,
commands_v2::v2_check_qobuzapp_handler, commands_v2::v2_check_qobuzapp_handler,
commands_v2::v2_register_qobuzapp_handler, commands_v2::v2_register_qobuzapp_handler,
@@ -6,7 +6,7 @@
import QualityBadge from '../QualityBadge.svelte'; import QualityBadge from '../QualityBadge.svelte';
import Dropdown from '../Dropdown.svelte'; import Dropdown from '../Dropdown.svelte';
import ViewTransition from '../ViewTransition.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 { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters';
import type { PurchasedAlbum, PurchasedTrack, PurchaseFormatOption, PurchaseDownloadProgress } from '$lib/types/purchases'; import type { PurchasedAlbum, PurchasedTrack, PurchaseFormatOption, PurchaseDownloadProgress } from '$lib/types/purchases';
@@ -108,6 +108,7 @@
try { try {
await downloadTrack(track.id, selectedFormatId, destination); await downloadTrack(track.id, selectedFormatId, destination);
downloadStatuses.set(track.id, 'complete'); downloadStatuses.set(track.id, 'complete');
await markTrackDownloaded(track.id, album.id, destination).catch(() => {});
} catch { } catch {
downloadStatuses.set(track.id, 'failed'); downloadStatuses.set(track.id, 'failed');
} }
@@ -124,6 +125,7 @@
try { try {
await downloadTrack(action, selectedFormatId, destination); await downloadTrack(action, selectedFormatId, destination);
downloadStatuses.set(action, 'complete'); downloadStatuses.set(action, 'complete');
await markTrackDownloaded(action, album.id, destination).catch(() => {});
} catch { } catch {
downloadStatuses.set(action, 'failed'); downloadStatuses.set(action, 'failed');
} }
+105 -24
View File
@@ -8,13 +8,13 @@
} from 'lucide-svelte'; } from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte'; import AlbumCard from '../AlbumCard.svelte';
import QualityBadge from '../QualityBadge.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 { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters';
import { import {
getHideUnavailable, setHideUnavailable, getHideUnavailable, setHideUnavailable,
getHiResOnly, setHiResOnly,
getHideDownloaded, setHideDownloaded, getHideDownloaded, setHideDownloaded,
} from '$lib/stores/purchasesStore'; } from '$lib/stores/purchasesStore';
import { getUserItem, setUserItem } from '$lib/utils/userStorage';
import type { PurchasedAlbum, PurchasedTrack, PurchaseResponse } from '$lib/types/purchases'; import type { PurchasedAlbum, PurchasedTrack, PurchaseResponse } from '$lib/types/purchases';
type PurchasesTab = 'albums' | 'tracks'; type PurchasesTab = 'albums' | 'tracks';
@@ -58,6 +58,7 @@
// Data // Data
let albums = $state<PurchasedAlbum[]>([]); let albums = $state<PurchasedAlbum[]>([]);
let tracks = $state<PurchasedTrack[]>([]); let tracks = $state<PurchasedTrack[]>([]);
let downloadedTrackIds = $state<Set<number>>(new Set());
let loading = $state(true); let loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@@ -73,8 +74,9 @@
// Albums: filter panel (LocalLibraryView pattern) // Albums: filter panel (LocalLibraryView pattern)
let showFilterPanel = $state(false); let showFilterPanel = $state(false);
let filterPanelRef = $state<HTMLDivElement | null>(null); let filterPanelRef = $state<HTMLDivElement | null>(null);
type QualityFilter = 'all' | 'hires' | 'cd' | 'lossy';
let filterHideUnavailable = $state(getHideUnavailable()); let filterHideUnavailable = $state(getHideUnavailable());
let filterHiResOnly = $state(getHiResOnly()); let filterQuality = $state<QualityFilter>((getUserItem('qbz-purchases-quality-filter') as QualityFilter) || 'all');
let filterHideDownloaded = $state(getHideDownloaded()); let filterHideDownloaded = $state(getHideDownloaded());
// Tracks: grouping // Tracks: grouping
@@ -84,7 +86,7 @@
// Persist filter changes // Persist filter changes
$effect(() => { setHideUnavailable(filterHideUnavailable); }); $effect(() => { setHideUnavailable(filterHideUnavailable); });
$effect(() => { setHiResOnly(filterHiResOnly); }); $effect(() => { setUserItem('qbz-purchases-quality-filter', filterQuality); });
$effect(() => { setHideDownloaded(filterHideDownloaded); }); $effect(() => { setHideDownloaded(filterHideDownloaded); });
const albumSortOptions = [ const albumSortOptions = [
@@ -94,17 +96,17 @@
{ value: 'quality' as SortBy, label: 'Quality' }, { value: 'quality' as SortBy, label: 'Quality' },
]; ];
const hasActiveFilters = $derived(filterHideUnavailable || filterHiResOnly || filterHideDownloaded); const hasActiveFilters = $derived(filterHideUnavailable || filterQuality !== 'all' || filterHideDownloaded);
const activeFilterCount = $derived( const activeFilterCount = $derived(
(filterHideUnavailable ? 1 : 0) + (filterHiResOnly ? 1 : 0) + (filterHideDownloaded ? 1 : 0) (filterHideUnavailable ? 1 : 0) + (filterQuality !== 'all' ? 1 : 0) + (filterHideDownloaded ? 1 : 0)
); );
function clearAllFilters() { function clearAllFilters() {
filterHideUnavailable = false; filterHideUnavailable = false;
filterHiResOnly = false; filterQuality = 'all';
filterHideDownloaded = false; filterHideDownloaded = false;
setHideUnavailable(false); setHideUnavailable(false);
setHiResOnly(false); setUserItem('qbz-purchases-quality-filter', 'all');
setHideDownloaded(false); setHideDownloaded(false);
} }
@@ -138,10 +140,18 @@
// ── Album filtering & sorting ── // ── 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[] { function applyAlbumFilters(list: PurchasedAlbum[]): PurchasedAlbum[] {
let result = list; let result = list;
if (filterHideUnavailable) result = result.filter((a) => a.downloadable); 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); if (filterHideDownloaded) result = result.filter((a) => !a.downloaded);
return result; return result;
} }
@@ -149,7 +159,7 @@
function applyTrackFilters(list: PurchasedTrack[]): PurchasedTrack[] { function applyTrackFilters(list: PurchasedTrack[]): PurchasedTrack[] {
let result = list; let result = list;
if (filterHideDownloaded) result = result.filter((track) => !track.downloaded); 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; return result;
} }
@@ -227,13 +237,35 @@
// ── Data loading ── // ── Data loading ──
function enrichWithDownloadStatus(
albumList: PurchasedAlbum[],
trackList: PurchasedTrack[],
dlIds: Set<number>,
): { 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() { async function loadPurchases() {
loading = true; loading = true;
error = null; error = null;
try { try {
const response: PurchaseResponse = await getPurchases(); const [response, dlIds] = await Promise.all([
albums = response.albums.items; getPurchases(),
tracks = response.tracks.items; getDownloadedTrackIds().catch(() => new Set<number>()),
]);
downloadedTrackIds = dlIds;
const enriched = enrichWithDownloadStatus(response.albums.items, response.tracks.items, dlIds);
albums = enriched.albums;
tracks = enriched.tracks;
} catch (err) { } catch (err) {
error = String(err); error = String(err);
} finally { } finally {
@@ -251,8 +283,9 @@
loading = true; loading = true;
try { try {
const response = await searchPurchases(searchQuery.trim()); const response = await searchPurchases(searchQuery.trim());
albums = response.albums.items; const enriched = enrichWithDownloadStatus(response.albums.items, response.tracks.items, downloadedTrackIds);
tracks = response.tracks.items; albums = enriched.albums;
tracks = enriched.tracks;
} catch (err) { } catch (err) {
error = String(err); error = String(err);
} finally { } finally {
@@ -437,11 +470,28 @@
<div class="filter-section-label">{$t('library.quality')}</div> <div class="filter-section-label">{$t('library.quality')}</div>
<div class="filter-checkboxes"> <div class="filter-checkboxes">
<label class="filter-checkbox"> <label class="filter-checkbox">
<input type="checkbox" bind:checked={filterHiResOnly} /> <input type="radio" name="quality-filter" value="all" bind:group={filterQuality} />
<span class="checkmark"></span> <span class="checkmark radio"></span>
<span class="label-text">{$t('purchases.filter.all')}</span>
</label>
<label class="filter-checkbox">
<input type="radio" name="quality-filter" value="hires" bind:group={filterQuality} />
<span class="checkmark radio"></span>
<span class="label-text">Hi-Res</span> <span class="label-text">Hi-Res</span>
<span class="label-hint">24bit+</span> <span class="label-hint">24bit+</span>
</label> </label>
<label class="filter-checkbox">
<input type="radio" name="quality-filter" value="cd" bind:group={filterQuality} />
<span class="checkmark radio"></span>
<span class="label-text">CD</span>
<span class="label-hint">16/44.1</span>
</label>
<label class="filter-checkbox">
<input type="radio" name="quality-filter" value="lossy" bind:group={filterQuality} />
<span class="checkmark radio"></span>
<span class="label-text">Lossy</span>
<span class="label-hint">MP3</span>
</label>
</div> </div>
</div> </div>
<div class="filter-section"> <div class="filter-section">
@@ -549,11 +599,28 @@
<div class="filter-section-label">{$t('library.quality')}</div> <div class="filter-section-label">{$t('library.quality')}</div>
<div class="filter-checkboxes"> <div class="filter-checkboxes">
<label class="filter-checkbox"> <label class="filter-checkbox">
<input type="checkbox" bind:checked={filterHiResOnly} /> <input type="radio" name="quality-filter" value="all" bind:group={filterQuality} />
<span class="checkmark"></span> <span class="checkmark radio"></span>
<span class="label-text">{$t('purchases.filter.all')}</span>
</label>
<label class="filter-checkbox">
<input type="radio" name="quality-filter" value="hires" bind:group={filterQuality} />
<span class="checkmark radio"></span>
<span class="label-text">Hi-Res</span> <span class="label-text">Hi-Res</span>
<span class="label-hint">24bit+</span> <span class="label-hint">24bit+</span>
</label> </label>
<label class="filter-checkbox">
<input type="radio" name="quality-filter" value="cd" bind:group={filterQuality} />
<span class="checkmark radio"></span>
<span class="label-text">CD</span>
<span class="label-hint">16/44.1</span>
</label>
<label class="filter-checkbox">
<input type="radio" name="quality-filter" value="lossy" bind:group={filterQuality} />
<span class="checkmark radio"></span>
<span class="label-text">Lossy</span>
<span class="label-hint">MP3</span>
</label>
</div> </div>
</div> </div>
<div class="filter-section"> <div class="filter-section">
@@ -1156,6 +1223,19 @@
transform: rotate(45deg) translateY(-1px); 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 { .filter-checkbox .label-text {
font-size: 12px; font-size: 12px;
color: var(--text-primary); color: var(--text-primary);
@@ -1233,16 +1313,17 @@
.unavailable-overlay { .unavailable-overlay {
position: absolute; position: absolute;
bottom: 36px; top: 0;
left: 0; left: 0;
right: 0; right: 0;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center; text-align: center;
padding: 4px 8px; padding: 16px;
background: rgba(0, 0, 0, 0.75);
color: var(--text-muted); color: var(--text-muted);
font-size: 11px; font-size: 11px;
border-radius: 4px;
margin: 0 8px;
} }
/* ── Albums list ── */ /* ── Albums list ── */
+23
View File
@@ -91,3 +91,26 @@ export async function downloadTrack(
destination destination
}); });
} }
// ── Downloaded Purchases Registry (V2 backend, SQLite) ──
export async function getDownloadedTrackIds(): Promise<Set<number>> {
const ids = await invoke<number[]>('v2_purchases_get_downloaded_track_ids');
return new Set(ids);
}
export async function markTrackDownloaded(
trackId: number,
albumId: string | undefined,
filePath: string
): Promise<void> {
return invoke<void>('v2_purchases_mark_downloaded', {
trackId,
albumId: albumId ?? null,
filePath
});
}
export async function removeDownloadedTrack(trackId: number): Promise<void> {
return invoke<void>('v2_purchases_remove_downloaded', { trackId });
}
-13
View File
@@ -26,19 +26,6 @@ export function setHideUnavailable(v: boolean): void {
setUserItem(KEY_HIDE_UNAVAILABLE, String(v)); 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 // Filter: hide downloaded
const KEY_HIDE_DOWNLOADED = 'qbz-purchases-hide-downloaded'; const KEY_HIDE_DOWNLOADED = 'qbz-purchases-hide-downloaded';
let hideDownloaded = getUserItem(KEY_HIDE_DOWNLOADED) === 'true'; let hideDownloaded = getUserItem(KEY_HIDE_DOWNLOADED) === 'true';