fix(purchases): persist download progress across navigation

Move download state from component-local $state() to a persistent
module-level Svelte store (purchaseDownloadStore). Downloads continue
in the background when navigating away from album detail, and
progress bars are restored when returning.

Closes #90
This commit is contained in:
vicrodh committed 2026-02-20 02:04:27 -06:00
1 parent 1ba4622828
commit d8f28385d9
3 files changed
+150 -63

No files matched your search

@@ -6,9 +6,11 @@
import QualityBadge from '../QualityBadge.svelte';
import Dropdown from '../Dropdown.svelte';
import ViewTransition from '../ViewTransition.svelte';
import { getAlbumDetail, getFormats, downloadAlbum, downloadTrack, markTrackDownloaded } from '$lib/services/purchases';
import { getAlbumDetail, getFormats } from '$lib/services/purchases';
import { purchaseDownloads, startAlbumDownload, startTrackDownload } from '$lib/stores/purchaseDownloadStore';
import type { TrackDownloadStatus } from '$lib/stores/purchaseDownloadStore';
import { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters';
import type { PurchasedAlbum, PurchasedTrack, PurchaseFormatOption, PurchaseDownloadProgress } from '$lib/types/purchases';
import type { PurchasedAlbum, PurchasedTrack, PurchaseFormatOption } from '$lib/types/purchases';
import type { DisplayTrack } from '$lib/types';
interface Props {
@@ -37,10 +39,11 @@
let loading = $state(true);
let error = $state<string | null>(null);
// Download state
let downloadStatuses = $state<Map<number, PurchaseDownloadProgress['status']>>(new Map());
let isDownloadingAll = $state(false);
let allComplete = $state(false);
// Download state (from persistent store — survives navigation)
const albumDlState = $derived($purchaseDownloads[albumId] ?? null);
const downloadStatuses = $derived(albumDlState?.trackStatuses ?? {});
const isDownloadingAll = $derived(albumDlState?.isDownloadingAll ?? false);
const allComplete = $derived(albumDlState?.allComplete ?? false);
function formatPurchaseDate(ts?: number): string {
if (!ts) return '';
@@ -69,8 +72,8 @@
if (fmt) selectedFormatId = fmt.id;
}
function getTrackStatus(trackId: number): PurchaseDownloadProgress['status'] | null {
return downloadStatuses.get(trackId) || null;
function getTrackStatus(trackId: number): TrackDownloadStatus | null {
return downloadStatuses[trackId] || null;
}
function groupByDisc(trackList: PurchasedTrack[]): Map<number, PurchasedTrack[]> {
@@ -109,6 +112,7 @@
}
async function promptForFolder(action: 'all' | number) {
if (!album || selectedFormatId === null) return;
try {
const { open } = await import('@tauri-apps/plugin-dialog');
const { audioDir } = await import('@tauri-apps/api/path');
@@ -120,50 +124,18 @@
title: get(t)('purchases.chooseFolder'),
});
if (dest && typeof dest === 'string') {
await executeDownload(action, dest);
if (action === 'all') {
const trackIds = (album.tracks?.items || []).map((track) => track.id);
startAlbumDownload(albumId, trackIds, selectedFormatId, dest);
} else {
startTrackDownload(albumId, action, selectedFormatId, dest);
}
}
} catch (err) {
console.error('Folder picker error:', err);
}
}
async function executeDownload(action: 'all' | number, destination: string) {
if (!album || selectedFormatId === null) return;
if (action === 'all') {
isDownloadingAll = true;
const trackItems = album.tracks?.items || [];
for (const track of trackItems) {
downloadStatuses.set(track.id, 'downloading');
downloadStatuses = new Map(downloadStatuses);
try {
const filePath = await downloadTrack(track.id, selectedFormatId, destination);
downloadStatuses.set(track.id, 'complete');
await markTrackDownloaded(track.id, album.id, filePath).catch(() => {});
} catch {
downloadStatuses.set(track.id, 'failed');
}
downloadStatuses = new Map(downloadStatuses);
}
isDownloadingAll = false;
const allDone = trackItems.every(
(track) => downloadStatuses.get(track.id) === 'complete'
);
if (allDone) allComplete = true;
} else {
downloadStatuses.set(action, 'downloading');
downloadStatuses = new Map(downloadStatuses);
try {
const filePath = await downloadTrack(action, selectedFormatId, destination);
downloadStatuses.set(action, 'complete');
await markTrackDownloaded(action, album.id, filePath).catch(() => {});
} catch {
downloadStatuses.set(action, 'failed');
}
downloadStatuses = new Map(downloadStatuses);
}
}
async function loadAlbum() {
loading = true;
error = null;
@@ -189,7 +161,7 @@
);
const isMultiDisc = $derived(discGroups.size > 1);
const completedCount = $derived(
Array.from(downloadStatuses.values()).filter((s) => s === 'complete').length
Object.values(downloadStatuses).filter((s) => s === 'complete').length
);
const totalTracks = $derived(album?.tracks?.items?.length || 0);
const totalDurationSeconds = $derived(
+4 -16
View File
@@ -7,7 +7,8 @@
} from 'lucide-svelte';
import AlbumCard from '../AlbumCard.svelte';
import QualityBadge from '../QualityBadge.svelte';
import { getPurchasesByType, getPurchaseIds, searchPurchases, getDownloadedTrackIds, getFormats, downloadTrack, markTrackDownloaded } from '$lib/services/purchases';
import { getPurchasesByType, getPurchaseIds, searchPurchases, getDownloadedTrackIds, getFormats } from '$lib/services/purchases';
import { allTrackStatuses, startTrackDownload } from '$lib/stores/purchaseDownloadStore';
import { showToast } from '$lib/stores/toastStore';
import { formatDuration, getQobuzImage } from '$lib/adapters/qobuzAdapters';
import {
@@ -378,10 +379,8 @@
};
}
let trackDownloadStatuses = $state<Map<number, 'downloading' | 'complete' | 'failed'>>(new Map());
function getTrackDownloadStatus(trackId: number): 'downloading' | 'complete' | 'failed' | null {
return trackDownloadStatuses.get(trackId) || null;
return $allTrackStatuses[trackId] || null;
}
async function handleTrackDownload(event: MouseEvent, track: PurchasedTrack) {
@@ -411,19 +410,8 @@
if (!dest || typeof dest !== 'string') return;
trackDownloadStatuses.set(track.id, 'downloading');
trackDownloadStatuses = new Map(trackDownloadStatuses);
const filePath = await downloadTrack(track.id, formats[0].id, dest);
await markTrackDownloaded(track.id, track.album.id, filePath).catch(() => {});
trackDownloadStatuses.set(track.id, 'complete');
trackDownloadStatuses = new Map(trackDownloadStatuses);
downloadedTrackIds.add(track.id);
downloadedTrackIds = new Set(downloadedTrackIds);
startTrackDownload(track.album.id, track.id, formats[0].id, dest);
} catch (err) {
trackDownloadStatuses.set(track.id, 'failed');
trackDownloadStatuses = new Map(trackDownloadStatuses);
console.error('Track download error:', err);
}
}
+127
View File
@@ -0,0 +1,127 @@
import { writable, derived, get } from 'svelte/store';
import { downloadTrack, markTrackDownloaded } from '$lib/services/purchases';
export type TrackDownloadStatus = 'downloading' | 'complete' | 'failed';
interface AlbumDownloadState {
trackStatuses: Record<number, TrackDownloadStatus>;
isDownloadingAll: boolean;
allComplete: boolean;
}
// Persistent store: albumId -> download state (survives component unmount)
export const purchaseDownloads = writable<Record<string, AlbumDownloadState>>({});
// Flat view of all track statuses across all albums (for PurchasesView)
export const allTrackStatuses = derived(purchaseDownloads, ($downloads) => {
const flat: Record<number, TrackDownloadStatus> = {};
for (const state of Object.values($downloads)) {
for (const [trackId, status] of Object.entries(state.trackStatuses)) {
flat[Number(trackId)] = status;
}
}
return flat;
});
function updateAlbumState(
albumId: string,
updater: (state: AlbumDownloadState) => AlbumDownloadState
) {
purchaseDownloads.update((all) => {
const current = all[albumId] ?? {
trackStatuses: {},
isDownloadingAll: false,
allComplete: false,
};
return { ...all, [albumId]: updater(current) };
});
}
export function startAlbumDownload(
albumId: string,
trackIds: number[],
formatId: number,
destination: string
) {
updateAlbumState(albumId, () => ({
trackStatuses: {},
isDownloadingAll: true,
allComplete: false,
}));
executeAlbumDownload(albumId, trackIds, formatId, destination);
}
async function executeAlbumDownload(
albumId: string,
trackIds: number[],
formatId: number,
destination: string
) {
for (const trackId of trackIds) {
updateAlbumState(albumId, (state) => ({
...state,
trackStatuses: { ...state.trackStatuses, [trackId]: 'downloading' },
}));
try {
const filePath = await downloadTrack(trackId, formatId, destination);
updateAlbumState(albumId, (state) => ({
...state,
trackStatuses: { ...state.trackStatuses, [trackId]: 'complete' },
}));
await markTrackDownloaded(trackId, albumId, filePath).catch(() => {});
} catch {
updateAlbumState(albumId, (state) => ({
...state,
trackStatuses: { ...state.trackStatuses, [trackId]: 'failed' },
}));
}
}
// Finalize
const currentState = get(purchaseDownloads)[albumId];
if (currentState) {
const allDone = trackIds.every(
(id) => currentState.trackStatuses[id] === 'complete'
);
updateAlbumState(albumId, (state) => ({
...state,
isDownloadingAll: false,
allComplete: allDone,
}));
}
}
export function startTrackDownload(
albumId: string,
trackId: number,
formatId: number,
destination: string
) {
updateAlbumState(albumId, (state) => ({
...state,
trackStatuses: { ...state.trackStatuses, [trackId]: 'downloading' },
}));
executeSingleTrackDownload(albumId, trackId, formatId, destination);
}
async function executeSingleTrackDownload(
albumId: string,
trackId: number,
formatId: number,
destination: string
) {
try {
const filePath = await downloadTrack(trackId, formatId, destination);
updateAlbumState(albumId, (state) => ({
...state,
trackStatuses: { ...state.trackStatuses, [trackId]: 'complete' },
}));
await markTrackDownloaded(trackId, albumId, filePath).catch(() => {});
} catch {
updateAlbumState(albumId, (state) => ({
...state,
trackStatuses: { ...state.trackStatuses, [trackId]: 'failed' },
}));
}
}