Files
jellytau/src/lib/stores/downloads.ts
T
dtourolle e083b53ee8 feat(downloads): WiFi-only network-type-aware download gating
Add a metered/cellular network detector so downloads honour a "WiFi
only" preference. Android reports network type via NetworkTypeMonitor;
Rust exposes it through download/network.rs and holds the queue pump when
on a metered connection, emitting a queue-wide waitingForNetwork event.
The frontend surfaces this via the networkType service and a
waitingForNetwork store flag.

TRACES: UR-053 | DR-074
2026-07-23 20:02:07 +02:00

657 lines
16 KiB
TypeScript

// Download manager state store
// TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017
import { writable, derived, get } from 'svelte/store';
import { commands } from '$lib/api/bindings';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
// Event listener state
let unlistenFn: UnlistenFn | null = null;
let isEventsInitialized = false;
export interface DownloadInfo {
id: number;
itemId: string;
userId: string;
filePath: string;
fileSize?: number;
mimeType?: string;
status: 'pending' | 'downloading' | 'completed' | 'failed' | 'paused';
progress: number;
bytesDownloaded: number;
queuedAt: string;
startedAt?: string;
completedAt?: string;
errorMessage?: string;
retryCount: number;
priority: number;
// Item metadata for display (audio)
itemName?: string;
artistName?: string;
albumName?: string;
// Video-specific metadata
seriesName?: string;
seasonName?: string;
episodeNumber?: number;
seasonNumber?: number;
qualityPreset?: string;
mediaType: 'audio' | 'video';
// Download source tracking
downloadSource: 'user' | 'auto';
}
export interface DownloadEvent {
type:
| 'queued'
| 'started'
| 'progress'
| 'completed'
| 'failed'
| 'paused'
| 'cancelled'
| 'waitingForNetwork';
/** Absent on 'waitingForNetwork', which is queue-wide rather than per-download. */
downloadId: number;
itemId: string;
bytesDownloaded?: number;
totalBytes?: number;
progress?: number;
filePath?: string;
error?: string;
}
export interface DownloadStats {
total: number;
activeCount: number;
queuedCount: number;
completedCount: number;
failedCount: number;
pausedCount: number;
}
interface DownloadsState {
downloads: Record<number, DownloadInfo>;
stats: DownloadStats;
}
/**
* True when the download queue is held because "WiFi Only" is enabled and the
* device is on a metered/cellular network. Pending rows stay pending; the queue
* resumes automatically when an acceptable network appears.
*
* TRACES: UR-053 | DR-074
*/
export const waitingForNetwork = writable(false);
function createDownloadsStore() {
const { subscribe, update, set } = writable<DownloadsState>({
downloads: {},
stats: {
total: 0,
activeCount: 0,
queuedCount: 0,
completedCount: 0,
failedCount: 0,
pausedCount: 0
}
});
// Prevent concurrent refresh calls (race condition protection)
let refreshInProgress = false;
let pendingRefreshRequest: { userId: string; statusFilter?: string[] } | null = null;
// Helper function to refresh downloads (avoids `this` binding issues)
async function refreshDownloads(userId: string, statusFilter?: string[]): Promise<void> {
// If a refresh is already in progress, queue this request instead
if (refreshInProgress) {
console.debug('🔄 Refresh already in progress, queuing request for user:', userId);
pendingRefreshRequest = { userId, statusFilter };
return;
}
refreshInProgress = true;
try {
console.log('🔄 Refreshing downloads for user:', userId);
const response = (await commands.getDownloads(
userId,
statusFilter ?? null
)) as unknown as { downloads: DownloadInfo[]; stats: DownloadStats };
console.log(' Got', response.downloads.length, 'downloads from backend');
console.log(' Stats:', response.stats);
update((state) => {
const downloadsMap: Record<number, DownloadInfo> = {};
for (const download of response.downloads) {
downloadsMap[download.id] = download;
}
// No count calculation - use pre-computed stats from Rust!
return {
downloads: downloadsMap,
stats: response.stats
};
});
} catch (error) {
console.error('Failed to refresh downloads:', error);
throw error;
} finally {
refreshInProgress = false;
// Process queued request if any
if (pendingRefreshRequest) {
const { userId: queuedUserId, statusFilter: queuedFilter } = pendingRefreshRequest;
pendingRefreshRequest = null;
await refreshDownloads(queuedUserId, queuedFilter);
}
}
}
return {
subscribe,
/**
* Queue a single item for download
*/
async downloadItem(
itemId: string,
userId: string,
filePath: string,
mimeType?: string,
priority?: number,
itemName?: string,
artistName?: string,
albumName?: string
): Promise<number> {
try {
console.log('📥 downloadItem called:', { itemId, userId, filePath, itemName, artistName, albumName });
const downloadId = await commands.downloadItem({
itemId,
userId,
filePath,
mimeType: mimeType ?? null,
priority: priority ?? null,
itemName: itemName ?? null,
artistName: artistName ?? null,
albumName: albumName ?? null,
expectedSize: null
});
console.log(' Got download ID from backend:', downloadId);
// Fetch download info and add to store
console.log(' Refreshing downloads...');
await refreshDownloads(userId);
console.log(' Refresh complete. Store state:', get({ subscribe }));
return downloadId;
} catch (error) {
console.error('Failed to queue download:', error);
throw error;
}
},
/**
* Queue an entire album for download
*/
async downloadAlbum(albumId: string, userId: string, basePath: string): Promise<number[]> {
try {
console.log('📥 downloadAlbum called:', { albumId, userId, basePath });
const downloadIds = await commands.downloadAlbum(albumId, userId, basePath);
console.log(' Got download IDs from backend:', downloadIds);
// Refresh downloads
await refreshDownloads(userId);
return downloadIds;
} catch (error) {
console.error('Failed to queue album download:', error);
throw error;
}
},
/**
* Queue a video item (movie/episode) for download with quality preset
*/
async downloadVideo(
itemId: string,
userId: string,
filePath: string,
mimeType?: string,
priority?: number,
itemName?: string,
qualityPreset?: string,
seriesName?: string,
seasonName?: string,
episodeNumber?: number,
seasonNumber?: number
): Promise<number> {
try {
console.log('🎬 downloadVideo called:', {
itemId,
userId,
filePath,
itemName,
qualityPreset,
seriesName
});
const downloadId = await commands.downloadVideo({
itemId,
userId,
filePath,
mimeType: mimeType ?? null,
priority: priority ?? null,
itemName: itemName ?? null,
qualityPreset: qualityPreset ?? null,
seriesName: seriesName ?? null,
seasonName: seasonName ?? null,
episodeNumber: episodeNumber ?? null,
seasonNumber: seasonNumber ?? null
});
console.log(' Got download ID from backend:', downloadId);
// Refresh downloads
await refreshDownloads(userId);
return downloadId;
} catch (error) {
console.error('Failed to queue video download:', error);
throw error;
}
},
/**
* Queue all episodes of a series for download
*/
async downloadSeries(
seriesId: string,
seriesName: string,
userId: string,
basePath: string,
qualityPreset?: string
): Promise<number[]> {
try {
console.log('📺 downloadSeries called:', {
seriesId,
seriesName,
userId,
basePath,
qualityPreset
});
const downloadIds = await commands.downloadSeries(
seriesId,
seriesName,
userId,
basePath,
qualityPreset ?? null
);
console.log(' Queued', downloadIds.length, 'episodes for download');
// Refresh downloads
await refreshDownloads(userId);
return downloadIds;
} catch (error) {
console.error('Failed to queue series download:', error);
throw error;
}
},
/**
* Queue all episodes of a season for download
*/
async downloadSeason(
seasonId: string,
seriesName: string,
seasonName: string,
seasonNumber: number,
userId: string,
basePath: string,
qualityPreset?: string
): Promise<number[]> {
try {
console.log('📺 downloadSeason called:', {
seasonId,
seriesName,
seasonName,
seasonNumber,
qualityPreset
});
const downloadIds = await commands.downloadSeason(
seasonId,
seriesName,
seasonName,
seasonNumber,
userId,
basePath,
qualityPreset ?? null
);
console.log(' Queued', downloadIds.length, 'episodes for download');
// Refresh downloads
await refreshDownloads(userId);
return downloadIds;
} catch (error) {
console.error('Failed to queue season download:', error);
throw error;
}
},
/**
* Pin an item's metadata (protects from cache clear)
*/
async pinItem(itemId: string): Promise<void> {
try {
await commands.pinItem(itemId);
} catch (error) {
console.error('Failed to pin item:', error);
throw error;
}
},
/**
* Unpin an item's metadata
*/
async unpinItem(itemId: string): Promise<void> {
try {
await commands.unpinItem(itemId);
} catch (error) {
console.error('Failed to unpin item:', error);
throw error;
}
},
/**
* Check if an item is pinned
*/
async isItemPinned(itemId: string): Promise<boolean> {
try {
return await commands.isItemPinned(itemId);
} catch (error) {
console.error('Failed to check pin status:', error);
return false;
}
},
/**
* Pause a download
*/
async pause(downloadId: number): Promise<void> {
try {
await commands.pauseDownload(downloadId);
} catch (error) {
console.error('Failed to pause download:', error);
throw error;
}
},
/**
* Resume a paused download
*/
async resume(downloadId: number): Promise<void> {
try {
await commands.resumeDownload(downloadId);
} catch (error) {
console.error('Failed to resume download:', error);
throw error;
}
},
/**
* Cancel a download
*/
async cancel(downloadId: number): Promise<void> {
try {
await commands.cancelDownload(downloadId);
} catch (error) {
console.error('Failed to cancel download:', error);
throw error;
}
},
/**
* Delete a completed download
*/
async delete(downloadId: number): Promise<void> {
try {
await commands.deleteDownload(downloadId);
update((state) => {
const { [downloadId]: removed, ...remaining } = state.downloads;
return { ...state, downloads: remaining };
});
} catch (error) {
console.error('Failed to delete download:', error);
throw error;
}
},
/**
* Refresh downloads list from backend
*/
refresh: refreshDownloads,
/**
* Update a specific download in the store (for event handling)
*/
updateDownload(downloadId: number, updates: Partial<DownloadInfo>): void {
update((state) => {
const download = state.downloads[downloadId];
if (!download) {
console.log(' Download not in store:', downloadId);
return state;
}
const updatedDownload = { ...download, ...updates };
const newDownloads = { ...state.downloads, [downloadId]: updatedDownload };
console.log(' Store updated for download', downloadId, ':', updates);
// No count calculation - stats remain as-is until next refresh
return {
downloads: newDownloads,
stats: state.stats
};
});
},
/**
* Remove a download from the store
*/
removeDownload(downloadId: number): void {
update((state) => {
const { [downloadId]: removed, ...remaining } = state.downloads;
if (!removed) return state;
// No count calculation - stats remain as-is until next refresh
return {
downloads: remaining,
stats: state.stats
};
});
}
};
}
export const downloads = createDownloadsStore();
// Derived stores
export const activeDownloads = derived(downloads, ($d) =>
Object.values($d.downloads).filter((d) => d.status === 'downloading')
);
export const completedDownloads = derived(downloads, ($d) =>
Object.values($d.downloads).filter((d) => d.status === 'completed')
);
export const pendingDownloads = derived(downloads, ($d) =>
Object.values($d.downloads).filter((d) => d.status === 'pending')
);
export const failedDownloads = derived(downloads, ($d) =>
Object.values($d.downloads).filter((d) => d.status === 'failed')
);
export const videoDownloads = derived(downloads, ($d) =>
Object.values($d.downloads).filter((d) => d.mediaType === 'video')
);
export const audioDownloads = derived(downloads, ($d) =>
Object.values($d.downloads).filter((d) => d.mediaType === 'audio' || !d.mediaType)
);
/**
* Initialize download event listeners.
* Should be called once when the app starts (e.g., in +layout.svelte).
*/
export async function initDownloadEvents(): Promise<void> {
if (isEventsInitialized) {
console.warn('Download events already initialized');
return;
}
try {
console.log('🎧 Setting up download event listener...');
unlistenFn = await listen<DownloadEvent>('download-event', (event) => {
const payload = event.payload;
console.log('📬 Received download event:', payload.type, 'for download:', payload.downloadId);
console.log(' Full event payload:', JSON.stringify(payload));
// Update the store based on event type
downloads.subscribe((state) => {
const download = state.downloads[payload.downloadId];
console.log(' Current download state:', download ? download.status : 'NOT IN STORE');
})(); // Immediately unsubscribe after reading
handleDownloadEvent(payload);
});
isEventsInitialized = true;
console.log('✅ Download event listener registered successfully');
} catch (err) {
console.error('❌ Failed to register download event listener:', err);
}
}
/**
* Clean up download event listeners.
* Should be called when the app is destroyed.
*/
export function cleanupDownloadEvents(): void {
if (unlistenFn) {
unlistenFn();
unlistenFn = null;
}
isEventsInitialized = false;
}
/**
* Check if the download event listener is initialized.
*/
export function isDownloadEventsInitialized(): boolean {
return isEventsInitialized;
}
/**
* Handle a download event and update the store.
*/
function handleDownloadEvent(payload: DownloadEvent): void {
const currentState = get(downloads);
const download = currentState.downloads[payload.downloadId];
switch (payload.type) {
case 'queued':
// Just increment queue count - the download will be fetched on refresh
break;
case 'started':
if (download) {
updateDownloadInStore(payload.downloadId, {
status: 'downloading',
startedAt: new Date().toISOString()
});
}
break;
case 'progress':
if (download && payload.progress !== undefined) {
updateDownloadInStore(payload.downloadId, {
progress: payload.progress,
bytesDownloaded: payload.bytesDownloaded || download.bytesDownloaded,
fileSize: payload.totalBytes || download.fileSize
});
}
break;
case 'completed':
if (download) {
// Persist to database
commands.markDownloadCompleted(
payload.downloadId,
payload.totalBytes || download.fileSize || download.bytesDownloaded,
payload.filePath || download.filePath
).catch((err) => console.error('Failed to persist download completion:', err));
updateDownloadInStore(payload.downloadId, {
status: 'completed',
progress: 1.0,
completedAt: new Date().toISOString(),
filePath: payload.filePath || download.filePath
});
}
break;
case 'failed':
if (download) {
// Persist to database
commands.markDownloadFailed(
payload.downloadId,
payload.error || 'Unknown error'
).catch((err) => console.error('Failed to persist download failure:', err));
updateDownloadInStore(payload.downloadId, {
status: 'failed',
errorMessage: payload.error
});
}
break;
case 'paused':
if (download) {
updateDownloadInStore(payload.downloadId, {
status: 'paused'
});
}
break;
case 'cancelled':
removeDownloadFromStore(payload.downloadId);
break;
case 'waitingForNetwork':
// Queue-wide, not tied to one download: the pump refused to start
// anything because WiFi-only is on and we're on a metered network.
waitingForNetwork.set(true);
break;
}
// Any per-download progress proves the gate isn't holding us any more.
if (payload.type === 'started' || payload.type === 'progress') {
waitingForNetwork.set(false);
}
}
/**
* Helper to update a download in the store.
*/
function updateDownloadInStore(downloadId: number, updates: Partial<DownloadInfo>): void {
console.log(' updateDownloadInStore:', downloadId, updates);
downloads.updateDownload(downloadId, updates);
}
/**
* Helper to remove a download from the store.
*/
function removeDownloadFromStore(downloadId: number): void {
console.log(' removeDownloadFromStore:', downloadId);
downloads.removeDownload(downloadId);
}