// Download manager state store // TRACES: UR-011, UR-013, UR-018 | DR-015, DR-017 import { writable, derived, get } from 'svelte/store'; import { invoke } from '@tauri-apps/api/core'; 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'; 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; stats: DownloadStats; } function createDownloadsStore() { const { subscribe, update, set } = writable({ 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 { // 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 invoke<{ downloads: DownloadInfo[]; stats: DownloadStats }>( 'get_downloads', { userId, statusFilter } ); console.log(' Got', response.downloads.length, 'downloads from backend'); console.log(' Stats:', response.stats); update((state) => { const downloadsMap: Record = {}; 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 { 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 { try { console.log('📥 downloadAlbum called:', { albumId, userId, basePath }); const downloadIds = await invoke('download_album', { 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 { 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 { try { console.log('📺 downloadSeries called:', { seriesId, seriesName, userId, basePath, qualityPreset }); const downloadIds = await invoke('download_series', { seriesId, seriesName, userId, basePath, qualityPreset }); 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 { try { console.log('📺 downloadSeason called:', { seasonId, seriesName, seasonName, seasonNumber, qualityPreset }); const downloadIds = await invoke('download_season', { seasonId, seriesName, seasonName, seasonNumber, userId, basePath, qualityPreset }); 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 { try { await invoke('pin_item', { itemId }); } catch (error) { console.error('Failed to pin item:', error); throw error; } }, /** * Unpin an item's metadata */ async unpinItem(itemId: string): Promise { try { await invoke('unpin_item', { itemId }); } catch (error) { console.error('Failed to unpin item:', error); throw error; } }, /** * Check if an item is pinned */ async isItemPinned(itemId: string): Promise { try { return await invoke('is_item_pinned', { itemId }); } catch (error) { console.error('Failed to check pin status:', error); return false; } }, /** * Pause a download */ async pause(downloadId: number): Promise { try { await invoke('pause_download', { downloadId }); } catch (error) { console.error('Failed to pause download:', error); throw error; } }, /** * Resume a paused download */ async resume(downloadId: number): Promise { try { await invoke('resume_download', { downloadId }); } catch (error) { console.error('Failed to resume download:', error); throw error; } }, /** * Cancel a download */ async cancel(downloadId: number): Promise { try { await invoke('cancel_download', { downloadId }); } catch (error) { console.error('Failed to cancel download:', error); throw error; } }, /** * Delete a completed download */ async delete(downloadId: number): Promise { try { await invoke('delete_download', { 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): 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 { if (isEventsInitialized) { console.warn('Download events already initialized'); return; } try { console.log('🎧 Setting up download event listener...'); unlistenFn = await listen('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 invoke('mark_download_completed', { downloadId: payload.downloadId, bytesDownloaded: payload.totalBytes || download.fileSize || download.bytesDownloaded, filePath: 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 invoke('mark_download_failed', { downloadId: payload.downloadId, errorMessage: 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; } } /** * Helper to update a download in the store. */ function updateDownloadInStore(downloadId: number, updates: Partial): 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); }