import { BaseItemKind } from '@jellyfin/sdk/lib/generated-client' import { useQueryClient } from '@tanstack/react-query' import { Parser } from 'm3u8-parser' import { ReactNode, useCallback, useEffect, useRef, useState } from 'react' import { MediaItem } from '../../api/jellyfin' import { usePatchQueries } from '../../hooks/usePatchQueries' import { useAudioStorageContext } from '../AudioStorageContext/AudioStorageContext' import { useJellyfinContext } from '../JellyfinContext/JellyfinContext' import { usePlaybackContext } from '../PlaybackContext/PlaybackContext' import { downloadedPlaylistManager } from '../../utils/downloadedPlaylistManager' import { DownloadContext } from './DownloadContext' import io from 'socket.io-client' const STORAGE_KEY = 'mediaTaskQueue' type Task = { mediaItem: MediaItem; action: 'download' | 'remove'; containerId?: string; retryCount?: number; playlistId?: string } export type IDownloadContext = ReturnType & { updateDownloadStatus: (downloadId: string, status: string | undefined, progress: number) => void } const useInitialState = () => { // Map of downloadId -> {progress, status} const [downloads, setDownloads] = useState<{ [id: string]: { progress: number; status?: string } }>({}); // Function to update download status based on WebSocket updates - DEFINED FIRST const updateDownloadStatus = useCallback((downloadId: string, status: string | undefined, progress: number) => { console.log(`Download status update: ${downloadId} - ${status} (${progress}%)`); setDownloads(prev => { const updatedDownloads = { ...prev, [downloadId]: { progress, status } }; // If download is complete, automatically remove it after 3 seconds if (progress >= 100 || status === 'completed') { setTimeout(() => { console.log(`πŸ—‘οΈ Auto-removing completed download: ${downloadId}`); setDownloads(current => { const { [downloadId]: removed, ...rest } = current; return rest; }); }, 3000); // 3 second delay before removing completed downloads } return updatedDownloads; }); }, []); // --- SOCKET.IO CLIENT FOR GLOBAL DOWNLOAD EVENTS --- const socketRef = useRef(null); useEffect(() => { // Determine backend URL let backendUrl; if (window.location.hostname === 'music.samcloud.ca') { backendUrl = window.location.origin; } else { backendUrl = import.meta.env.VITE_BACKEND_URL || 'http://localhost:5140'; } socketRef.current = io(backendUrl); // Listen for download progress events socketRef.current.on('update_status', (data: any) => { console.log('πŸ”„ WebSocket download update received:', data); // Debug log // Handle the backend's data structure: {history: [array_of_downloads]} if (data.history && Array.isArray(data.history)) { console.log(`πŸ“¦ Processing ${data.history.length} download updates from history`); data.history.forEach((download: any) => { if (download.id) { console.log(`πŸ“₯ Updating download ${download.id}: ${download.status} (${download.progress || 0}%)`); updateDownloadStatus(download.id, download.status, download.progress || 0); } }); } // Also handle direct download updates (for backward compatibility) else if (data.id && data.status) { console.log(`πŸ“₯ Direct download update: ${data.id}: ${data.status} (${data.progress || 0}%)`); updateDownloadStatus(data.id, data.status, data.progress || 0); } }); return () => { if (socketRef.current) { socketRef.current.disconnect(); } }; }, [updateDownloadStatus]); // Add updateDownloadStatus as dependency // --- END SOCKET.IO CLIENT --- const api = useJellyfinContext() const playback = usePlaybackContext() const audioStorage = useAudioStorageContext() const { patchMediaItem, patchMediaItems } = usePatchQueries() const queryClient = useQueryClient() const [storageStats, setStorageStats] = useState({ usage: 0, indexedDB: 0, trackCount: 0 }) const refreshStorageStats = useCallback(async () => { try { const count = await audioStorage.getTrackCount() // eslint-disable-next-line @typescript-eslint/no-explicit-any const estimate: any = await navigator.storage?.estimate() setStorageStats({ usage: estimate?.usage || 0, indexedDB: estimate?.usageDetails?.indexedDB || 0, trackCount: count, }) } catch (error) { console.error('Failed to load storage stats:', error) } }, [audioStorage]) useEffect(() => { refreshStorageStats() }, [refreshStorageStats]) const [queue, setQueue] = useState(() => { try { const stored = localStorage.getItem(STORAGE_KEY) return stored ? (JSON.parse(stored) as Task[]) : [] } catch (e) { console.error('Failed to load media task queue:', e) return [] } }) const processingRef = useRef(false) const abortControllerRef = useRef(null) // Persist queue useEffect(() => { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(queue)) } catch (e) { console.error('Failed to save media task queue:', e) } }, [queue]) // Enqueue download const addToDownloads = (items: MediaItem[], container: MediaItem | undefined) => { const containerId = container?.Id patchMediaItems( items.map(item => item.Id), item => ({ ...item, offlineState: 'downloading' }) ) if (containerId) { patchMediaItem(containerId, item => ({ ...item, offlineState: 'downloading' })) } setQueue(prev => { const filtered = prev.filter(task => !items.some(i => i.Id === task.mediaItem.Id)) // For album downloads, preserve track order by using IndexNumber const sortedItems = [...items].sort((a, b) => { const ai = (a as any).IndexNumber || 0; const bi = (b as any).IndexNumber || 0; if (ai === bi) return (a.Name || '').localeCompare(b.Name || ''); return ai - bi; }); const newTasks: Task[] = sortedItems.map(item => ({ mediaItem: item, action: 'download' as const, containerId, retryCount: 0 })) if (containerId) { newTasks.push({ mediaItem: container, action: 'download', retryCount: 0 }) } return newTasks.length ? [...filtered, ...newTasks] : filtered }) } // Enqueue removal const removeFromDownloads = (items: MediaItem[], container: MediaItem | undefined) => { const containerId = container?.Id patchMediaItems( items.map(item => item.Id), item => ({ ...item, offlineState: 'deleting' }) ) if (containerId) { patchMediaItem(containerId, item => ({ ...item, offlineState: 'deleting' })) } setQueue(prev => { const filtered = prev.filter(task => !items.some(i => i.Id === task.mediaItem.Id)) const newTasks: Task[] = items.map(item => ({ mediaItem: item, action: 'remove' as const, containerId })) if (containerId) { newTasks.push({ mediaItem: container, action: 'remove' }) } return newTasks.length ? [...filtered, ...newTasks] : filtered }) } const clearQueue = useCallback(() => { if (abortControllerRef.current) { abortControllerRef.current.abort('clearQueue') abortControllerRef.current = null } queue.forEach(({ mediaItem }) => { patchMediaItem(mediaItem.Id, item => ({ ...item, offlineState: item.offlineState === 'downloading' ? undefined : item.offlineState === 'deleting' ? 'downloaded' : item.offlineState, })) }) setQueue([]) }, [queue, patchMediaItem]) // Process queue tasks one at a time useEffect(() => { const runNext = async () => { if (processingRef.current) return const next = queue[0] if (!next) return processingRef.current = true abortControllerRef.current = new AbortController() const signal = abortControllerRef.current.signal const { mediaItem, action } = next try { if (action === 'download') { // Check if this is a Spotify track if (mediaItem.SpotifyId) { // Handle Spotify track download via backend API try { // Import enhanced fetch for better network reliability const { enhancedFetch } = await import('../../utils/connectionRecovery'); const response = await enhancedFetch('/api/download/spotify', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ spotifyId: mediaItem.SpotifyId, trackName: mediaItem.Name, artistName: mediaItem.Artists?.[0] || 'Unknown Artist', }), signal, }) if (!response.ok) { throw new Error(`HTTP ${response.status}`) } // For now, we'll just mark it as downloaded // In the future, we might want to actually store the downloaded file patchMediaItem(mediaItem.Id, item => ({ ...item, offlineState: 'downloaded' })) } catch (error) { console.error(`Spotify download failed for ${mediaItem.SpotifyId}`, error) throw error } } else { // Regular Jellyfin track handling const already = await audioStorage.hasTrack(mediaItem.Id) if (already) { console.log(`Track ${mediaItem.Name} already downloaded, skipping`); patchMediaItem(mediaItem.Id, item => ({ ...item, offlineState: 'downloaded' })); } else { console.log(`Starting download: ${mediaItem.Name} by ${mediaItem.Artists?.[0] || 'Unknown Artist'}`); // Update progress to show download starting updateDownloadStatus(mediaItem.Id, 'downloading', 0); // The mediaItem object is saved so we modify it directly mediaItem.offlineState = 'downloaded' if (mediaItem.Type === BaseItemKind.Audio) { // Update progress to show processing updateDownloadStatus(mediaItem.Id, 'processing', 25); const isTranscoded = [128000, 192000, 256000, 320000].includes(playback.bitrate) const streamUrl = api.getStreamUrl(mediaItem.Id, playback.bitrate) if (isTranscoded) { updateDownloadStatus(mediaItem.Id, 'downloading', 50); const { playlist, ts } = await downloadTranscodedTrack(streamUrl, signal) updateDownloadStatus(mediaItem.Id, 'saving', 75); await audioStorage.saveTrack(mediaItem.Id, { type: 'm3u8', timestamp: Date.now(), mediaItem, bitrate: playback.bitrate, playlist, ts, containerId: next.containerId, }) // Track downloaded item for playlist management if (next.playlistId) { downloadedPlaylistManager.addTrackToPlaylist(next.playlistId, mediaItem.Id) } } else { updateDownloadStatus(mediaItem.Id, 'downloading', 50); const response = await fetch(streamUrl, { signal }) if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`) updateDownloadStatus(mediaItem.Id, 'saving', 75); const blob = await response.blob() await audioStorage.saveTrack(mediaItem.Id, { type: 'song', timestamp: Date.now(), mediaItem, bitrate: playback.bitrate, blob, containerId: next.containerId, }) // Track downloaded item for playlist management if (next.playlistId) { downloadedPlaylistManager.addTrackToPlaylist(next.playlistId, mediaItem.Id) } } } else { await audioStorage.saveTrack(mediaItem.Id, { type: 'container', timestamp: Date.now(), mediaItem, bitrate: playback.bitrate, }) } // Mark as completed updateDownloadStatus(mediaItem.Id, 'downloaded', 100); patchMediaItem(mediaItem.Id, item => ({ ...item, offlineState: 'downloaded' })); console.log(`Successfully downloaded: ${mediaItem.Name}`); } } } else if (action === 'remove') { await audioStorage.removeTrack(mediaItem.Id) patchMediaItem(mediaItem.Id, item => ({ ...item, offlineState: undefined })) } queryClient.invalidateQueries({ queryKey: ['downloads'] }) refreshStorageStats() } catch (error) { console.error(`Task failed for ${action} id=${mediaItem.Id}`, error) // Add retry logic for failed downloads if (action === 'download') { const currentTask = queue[0]; const retryCount = (currentTask as any).retryCount || 0; if (retryCount < 3) { // Retry up to 3 times console.log(`Retrying download for ${mediaItem.Name} (attempt ${retryCount + 1}/3)`); // Add the task back to the queue with retry count setQueue(prev => [ { ...currentTask, retryCount: retryCount + 1 } as Task, ...prev.slice(1) ]); // Don't mark as failed yet, let it retry processingRef.current = false; return; // Skip the finally block to avoid removing from queue } else { console.error(`Download failed permanently for ${mediaItem.Name} after 3 retries`); patchMediaItem(mediaItem.Id, item => ({ ...item, offlineState: undefined })); } } else if (action === 'remove') { patchMediaItem(mediaItem.Id, item => ({ ...item, offlineState: 'downloaded' })) } } finally { abortControllerRef.current = null setQueue(prev => prev.slice(1)) processingRef.current = false } } runNext() }, [api, audioStorage, refreshStorageStats, patchMediaItem, playback, queryClient, queue]) // We need the addToDownloads in jellyfin API but we don't want to cause unnecessary re-renders window.addToDownloads = addToDownloads return { addToDownloads, removeFromDownloads, storageStats, refreshStorageStats, queueCount: queue.length, clearQueue, updateDownloadStatus, downloads, queue, // Expose the queue so we can get track information isTrackDownloaded: (trackId: string) => { return downloads[trackId]?.progress >= 100 || audioStorage.hasTrack(trackId); }, getDownloadedTracks: () => { return Object.keys(downloads).filter(id => downloads[id].progress >= 100); }, getActiveDownloadsWithInfo: () => { // Return active downloads with their track information from the queue const activeDownloadIds = Object.keys(downloads).filter(id => downloads[id].progress < 100); return activeDownloadIds.map(id => { const queueItem = queue.find(task => task.mediaItem.Id === id); return { id, progress: downloads[id].progress, status: downloads[id].status, trackName: queueItem?.mediaItem.Name || 'Unknown Track', artistName: queueItem?.mediaItem.Artists?.[0] || 'Unknown Artist', albumName: queueItem?.mediaItem.Album || 'Unknown Album' }; }); } } } export const DownloadContextProvider = ({ children }: { children: ReactNode }) => { const initialState = useInitialState() return {children} } const downloadTranscodedTrack = async ( manifestUrl: string, signal?: AbortSignal ): Promise<{ playlist: Blob; ts: Blob[] }> => { // 1. Fetch the playlist (master or media) const manifestText = await (await fetch(manifestUrl, { signal })).text() // 2. Parse it const parser = new Parser() parser.push(manifestText) parser.end() const baseUrl = manifestUrl.replace(/\/[^/]+$/, '/') const playlistBlob = new Blob([manifestText], { type: 'application/vnd.apple.mpegurl' }) // 3a. If it's a master playlist, recurse into the first variant if (parser.manifest.playlists && parser.manifest.playlists.length > 0) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const variantUri = (parser as any).manifest.playlists[0].uri const variantUrl = new URL(variantUri, baseUrl).toString() return await downloadTranscodedTrack(variantUrl, signal) } // 3b. Otherwise it’s a media playlist: grab its segments const segments = parser.manifest.segments if (!segments || segments.length === 0) { throw new Error('No segments found in media playlist') } // 4. Download each TS segment const tsBlobs: Blob[] = [] for (const { uri } of segments) { const segUrl = new URL(uri, baseUrl).toString() const res = await fetch(segUrl, { signal }) if (!res.ok) throw new Error(`Segment error ${res.status}`) tsBlobs.push(await res.blob()) } // 5. Return the media playlist and its TS segments return { playlist: playlistBlob, ts: tsBlobs } }