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:
1 parent
eb6830430e
commit
42915197e3
7 files changed
+236
-38
No files matched your search
@@ -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)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ── */
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
Reference in new issue
Block a user