mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
fix(offline): cancellable downloads + stable sidebar progress toast (#694)
* fix(sidebar): keep offline-download toast from squishing in a short window The toast lives in the sidebar nav flex column; without flex-shrink: 0 the column compressed it vertically when the main window was small. The label now also ellipsis-truncates instead of overflowing on a narrow sidebar. * fix(offline): make offline downloads cancellable down to the Rust transfer A running offline download could not be stopped — the sidebar X button only dropped not-yet-started tracks between batches of 8, and the Rust transfer had no cancellation path at all, so in-flight HTTP streams always ran to completion. Add an offline_cancel_flags() registry (mirroring sync_cancel_flags for the device-sync side) plus additive cancel_offline_downloads / clear_offline_cancel commands. download_track_offline takes an optional download_id, checks the flag right after acquiring its semaphore slot, and threads it through finalize_streamed_download / stream_to_file so an in-flight stream aborts at the next chunk — the partial .part file is cleaned up by the existing error path. * fix(offline): cancel per-track and clear the sidebar toast immediately downloadAlbum tags each run with a downloadId, checks for cancellation before every track instead of once per 8-track batch (which never re-ran for albums of 8 or fewer tracks), and persists tracks that finished before the cancel so they are not orphaned on disk. cancelDownload / cancelAllDownloads drop every job for the album and call cancel_offline_downloads so Rust aborts the in-flight transfers — the toast disappears at once instead of lingering on stuck rows. Adds offlineJobStore cancellation tests. * docs(changelog): offline download cancel button + toast sizing fixes
This commit is contained in:
committed by
GitHub
parent
946528350c
commit
b4c8ed4b65
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { useOfflineJobStore, cancelledDownloads, type DownloadJob } from './offlineJobStore';
|
||||
|
||||
function job(over: Partial<DownloadJob>): DownloadJob {
|
||||
return {
|
||||
trackId: 't',
|
||||
albumId: 'a',
|
||||
albumName: 'A',
|
||||
trackTitle: 'T',
|
||||
trackIndex: 0,
|
||||
totalTracks: 1,
|
||||
status: 'queued',
|
||||
downloadId: 'a-1',
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useOfflineJobStore.setState({ jobs: [], bulkProgress: {} });
|
||||
cancelledDownloads.clear();
|
||||
});
|
||||
|
||||
describe('offlineJobStore cancellation', () => {
|
||||
it('cancelAllDownloads drops queued + downloading jobs but keeps settled ones', () => {
|
||||
const calls: string[][] = [];
|
||||
onInvoke('cancel_offline_downloads', (a: unknown) => {
|
||||
calls.push((a as { downloadIds: string[] }).downloadIds);
|
||||
});
|
||||
useOfflineJobStore.setState({
|
||||
jobs: [
|
||||
job({ trackId: 'q', status: 'queued' }),
|
||||
job({ trackId: 'd', status: 'downloading' }),
|
||||
job({ trackId: 'done', status: 'done' }),
|
||||
job({ trackId: 'err', status: 'error' }),
|
||||
],
|
||||
bulkProgress: {},
|
||||
});
|
||||
|
||||
useOfflineJobStore.getState().cancelAllDownloads();
|
||||
|
||||
// Only settled jobs survive → the sidebar toast clears.
|
||||
expect(useOfflineJobStore.getState().jobs.map(j => j.status).sort()).toEqual(['done', 'error']);
|
||||
expect(cancelledDownloads.has('a')).toBe(true);
|
||||
// Rust is told to abort the in-flight transfers for this download id.
|
||||
expect(calls).toEqual([['a-1']]);
|
||||
});
|
||||
|
||||
it('cancelDownload drops every job for one album and leaves others running', () => {
|
||||
const calls: string[][] = [];
|
||||
onInvoke('cancel_offline_downloads', (a: unknown) => {
|
||||
calls.push((a as { downloadIds: string[] }).downloadIds);
|
||||
});
|
||||
useOfflineJobStore.setState({
|
||||
jobs: [
|
||||
job({ trackId: 't1', albumId: 'a', status: 'downloading', downloadId: 'a-1' }),
|
||||
job({ trackId: 't2', albumId: 'b', status: 'downloading', downloadId: 'b-1' }),
|
||||
],
|
||||
bulkProgress: {},
|
||||
});
|
||||
|
||||
useOfflineJobStore.getState().cancelDownload('a');
|
||||
|
||||
expect(useOfflineJobStore.getState().jobs.map(j => j.albumId)).toEqual(['b']);
|
||||
expect(cancelledDownloads.has('a')).toBe(true);
|
||||
expect(calls).toEqual([['a-1']]);
|
||||
});
|
||||
|
||||
it('cancelAllDownloads with nothing active does not call into Rust', () => {
|
||||
let called = false;
|
||||
onInvoke('cancel_offline_downloads', () => {
|
||||
called = true;
|
||||
});
|
||||
useOfflineJobStore.setState({
|
||||
jobs: [job({ status: 'done' }), job({ status: 'error' })],
|
||||
bulkProgress: {},
|
||||
});
|
||||
|
||||
useOfflineJobStore.getState().cancelAllDownloads();
|
||||
|
||||
expect(called).toBe(false);
|
||||
expect(useOfflineJobStore.getState().jobs).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from 'zustand';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
export interface DownloadJob {
|
||||
trackId: string;
|
||||
@@ -8,6 +9,8 @@ export interface DownloadJob {
|
||||
trackIndex: number;
|
||||
totalTracks: number;
|
||||
status: 'queued' | 'downloading' | 'done' | 'error';
|
||||
/** Unique per `downloadAlbum` run — keys the Rust-side cancellation flag. */
|
||||
downloadId: string;
|
||||
}
|
||||
|
||||
interface OfflineJobState {
|
||||
@@ -17,30 +20,41 @@ interface OfflineJobState {
|
||||
cancelAllDownloads: () => void;
|
||||
}
|
||||
|
||||
// Module-level cancellation set — checked by downloadAlbum before each batch.
|
||||
// Module-level cancellation set — checked by downloadAlbum before each track.
|
||||
export const cancelledDownloads = new Set<string>();
|
||||
|
||||
/** Tells Rust to abort any in-flight `download_track_offline` calls for these jobs. */
|
||||
function abortDownloadsInRust(jobs: DownloadJob[]) {
|
||||
const downloadIds = [...new Set(jobs.map(j => j.downloadId).filter(Boolean))];
|
||||
if (downloadIds.length > 0) {
|
||||
invoke('cancel_offline_downloads', { downloadIds }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export const useOfflineJobStore = create<OfflineJobState>()((set, get) => ({
|
||||
jobs: [],
|
||||
bulkProgress: {},
|
||||
|
||||
cancelDownload: (albumId) => {
|
||||
cancelledDownloads.add(albumId);
|
||||
// Remove queued (not yet started) jobs immediately so the counter drops.
|
||||
// Abort the in-flight Rust transfers, then drop every job for this album
|
||||
// (queued AND downloading) so the sidebar toast clears right away.
|
||||
abortDownloadsInRust(get().jobs.filter(j => j.albumId === albumId));
|
||||
set(state => ({
|
||||
jobs: state.jobs.filter(j => !(j.albumId === albumId && j.status === 'queued')),
|
||||
jobs: state.jobs.filter(j => j.albumId !== albumId),
|
||||
}));
|
||||
},
|
||||
|
||||
cancelAllDownloads: () => {
|
||||
const unique = [...new Set(
|
||||
get().jobs
|
||||
.filter(j => j.status === 'queued' || j.status === 'downloading')
|
||||
.map(j => j.albumId),
|
||||
)];
|
||||
unique.forEach(id => cancelledDownloads.add(id));
|
||||
const active = get().jobs.filter(
|
||||
j => j.status === 'queued' || j.status === 'downloading',
|
||||
);
|
||||
[...new Set(active.map(j => j.albumId))].forEach(id => cancelledDownloads.add(id));
|
||||
abortDownloadsInRust(active);
|
||||
// Keep only already-settled jobs (done/error) — the active ones are gone,
|
||||
// so the toast disappears instead of lingering on stuck "downloading" rows.
|
||||
set(state => ({
|
||||
jobs: state.jobs.filter(j => j.status !== 'queued'),
|
||||
jobs: state.jobs.filter(j => j.status !== 'queued' && j.status !== 'downloading'),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -118,6 +118,9 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
const CONCURRENCY = 8;
|
||||
const trackIds = songs.map(s => s.id);
|
||||
const jobStore = useOfflineJobStore;
|
||||
// Unique per run — keys the Rust-side cancellation flag so the X button
|
||||
// can abort in-flight transfers, not just stop queuing new ones.
|
||||
const downloadId = `${albumId}-${Date.now()}`;
|
||||
|
||||
// Pre-flight: verify the target directory is accessible before queuing anything.
|
||||
const customDir = useAuthStore.getState().offlineDownloadDir || null;
|
||||
@@ -149,6 +152,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
trackIndex: i,
|
||||
totalTracks: songs.length,
|
||||
status: 'queued' as const,
|
||||
downloadId,
|
||||
})),
|
||||
],
|
||||
}));
|
||||
@@ -160,7 +164,13 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
// Abort if the user cancelled this download.
|
||||
if (cancelledDownloads.has(albumId)) {
|
||||
cancelledDownloads.delete(albumId);
|
||||
// Persist whatever finished before the cancel so files already on
|
||||
// disk are not orphaned, then drop the remaining jobs.
|
||||
if (Object.keys(completedTracks).length > 0) {
|
||||
set(state => ({ tracks: { ...state.tracks, ...completedTracks } }));
|
||||
}
|
||||
jobStore.setState(state => ({ jobs: state.jobs.filter(j => j.albumId !== albumId) }));
|
||||
invoke('clear_offline_cancel', { downloadId }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -180,6 +190,11 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
const results = await Promise.all(
|
||||
batch.map(async song => {
|
||||
const suffix = song.suffix || 'mp3';
|
||||
// Skip tracks not yet started once the user cancels mid-batch —
|
||||
// the per-batch check above only catches whole batches.
|
||||
if (cancelledDownloads.has(albumId)) {
|
||||
return { song, suffix, localPath: null as string | null, error: 'CANCELLED' };
|
||||
}
|
||||
try {
|
||||
const localPath = await invoke<string>('download_track_offline', {
|
||||
trackId: song.id,
|
||||
@@ -187,6 +202,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
url: buildStreamUrl(song.id),
|
||||
suffix,
|
||||
customDir,
|
||||
downloadId,
|
||||
});
|
||||
return { song, suffix, localPath, error: null as string | null };
|
||||
} catch (err) {
|
||||
@@ -233,6 +249,9 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
if (j.albumId !== albumId) return j;
|
||||
const r = resultMap.get(j.trackId);
|
||||
if (!r) return j;
|
||||
// A cancelled track is not a failure — leave the job for the
|
||||
// cancel path to drop rather than flashing it red.
|
||||
if (r.error === 'CANCELLED') return j;
|
||||
return { ...j, status: r.localPath ? 'done' : 'error' };
|
||||
}),
|
||||
}));
|
||||
@@ -240,6 +259,7 @@ export const useOfflineStore = create<OfflineState>()(
|
||||
|
||||
// Persist all completed tracks in ONE localStorage write.
|
||||
set(state => ({ tracks: { ...state.tracks, ...completedTracks } }));
|
||||
invoke('clear_offline_cancel', { downloadId }).catch(() => {});
|
||||
|
||||
// Clear completed jobs after a short delay.
|
||||
setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user