// 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"; import { createLogger } from "$lib/utils/logger"; const log = createLogger("Downloads"); // 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; 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({ 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) { log.debug("🔄 Refresh already in progress, queuing request for user:", userId); pendingRefreshRequest = { userId, statusFilter }; return; } refreshInProgress = true; try { log.debug("🔄 Refreshing downloads for user:", userId); const response = (await commands.getDownloads(userId, statusFilter ?? null)) as unknown as { downloads: DownloadInfo[]; stats: DownloadStats; }; log.debug(" Got", response.downloads.length, "downloads from backend"); log.debug(" 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) { log.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 { log.debug("📥 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, }); log.debug(" Got download ID from backend:", downloadId); // Fetch download info and add to store log.debug(" Refreshing downloads..."); await refreshDownloads(userId); log.debug(" Refresh complete. Store state:", get({ subscribe })); return downloadId; } catch (error) { log.error("Failed to queue download:", error); throw error; } }, /** * Queue an entire album for download. * * The backend does all of it — listing the album's tracks, queueing them, * resolving each stream URL and starting the queue. It returns the queued * row ids for reporting only; nothing here pairs them back to tracks. * * TRACES: UR-018, UR-055 | DR-173 */ async downloadAlbum( handle: string, albumId: string, userId: string, basePath: string, ): Promise { try { log.debug("📥 downloadAlbum called:", { albumId, userId, basePath }); const downloadIds = await commands.downloadAlbum(handle, albumId, userId, basePath); log.debug(" Got download IDs from backend:", downloadIds); // Refresh downloads await refreshDownloads(userId); return downloadIds; } catch (error) { log.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 { log.debug("🎬 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, }); log.debug(" Got download ID from backend:", downloadId); // Refresh downloads await refreshDownloads(userId); return downloadId; } catch (error) { log.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 { log.debug("📺 downloadSeries called:", { seriesId, seriesName, userId, basePath, qualityPreset, }); const downloadIds = await commands.downloadSeries( seriesId, seriesName, userId, basePath, qualityPreset ?? null, ); log.debug(" Queued", downloadIds.length, "episodes for download"); // Refresh downloads await refreshDownloads(userId); return downloadIds; } catch (error) { log.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 { log.debug("📺 downloadSeason called:", { seasonId, seriesName, seasonName, seasonNumber, qualityPreset, }); const downloadIds = await commands.downloadSeason( seasonId, seriesName, seasonName, seasonNumber, userId, basePath, qualityPreset ?? null, ); log.debug(" Queued", downloadIds.length, "episodes for download"); // Refresh downloads await refreshDownloads(userId); return downloadIds; } catch (error) { log.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 commands.pinItem(itemId); } catch (error) { log.error("Failed to pin item:", error); throw error; } }, /** * Unpin an item's metadata */ async unpinItem(itemId: string): Promise { try { await commands.unpinItem(itemId); } catch (error) { log.error("Failed to unpin item:", error); throw error; } }, /** * Check if an item is pinned */ async isItemPinned(itemId: string): Promise { try { return await commands.isItemPinned(itemId); } catch (error) { log.error("Failed to check pin status:", error); return false; } }, /** * Pause a download */ async pause(downloadId: number): Promise { try { await commands.pauseDownload(downloadId); } catch (error) { log.error("Failed to pause download:", error); throw error; } }, /** * Resume a paused download */ async resume(downloadId: number): Promise { try { await commands.resumeDownload(downloadId); } catch (error) { log.error("Failed to resume download:", error); throw error; } }, /** * Cancel a download */ async cancel(downloadId: number): Promise { try { await commands.cancelDownload(downloadId); } catch (error) { log.error("Failed to cancel download:", error); throw error; } }, /** * Delete a completed download */ async delete(downloadId: number): Promise { try { await commands.deleteDownload(downloadId); update((state) => { const { [downloadId]: removed, ...remaining } = state.downloads; return { ...state, downloads: remaining }; }); } catch (error) { log.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) { log.debug(" Download not in store:", downloadId); return state; } const updatedDownload = { ...download, ...updates }; const newDownloads = { ...state.downloads, [downloadId]: updatedDownload }; log.debug(" 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) { log.warn("Download events already initialized"); return; } try { log.debug("🎧 Setting up download event listener..."); unlistenFn = await listen("download-event", (event) => { const payload = event.payload; log.debug("📬 Received download event:", payload.type, "for download:", payload.downloadId); log.debug(" Full event payload:", JSON.stringify(payload)); // Update the store based on event type downloads.subscribe((state) => { const download = state.downloads[payload.downloadId]; log.debug(" Current download state:", download ? download.status : "NOT IN STORE"); })(); // Immediately unsubscribe after reading handleDownloadEvent(payload); }); isEventsInitialized = true; log.debug("✅ Download event listener registered successfully"); } catch (err) { log.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) => log.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) => log.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): void { log.debug(" updateDownloadInStore:", downloadId, updates); downloads.updateDownload(downloadId, updates); } /** * Helper to remove a download from the store. */ function removeDownloadFromStore(downloadId: number): void { log.debug(" removeDownloadFromStore:", downloadId); downloads.removeDownload(downloadId); }