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 { DownloadContext } from './DownloadContext' import io from 'socket.io-client' const STORAGE_KEY = 'mediaTaskQueue' type Task = { mediaItem: MediaItem; action: 'download' | 'remove'; containerId?: string } export type IDownloadContext = ReturnType & { updateDownloadStatus: (downloadId: string, status: string | undefined, progress: number) => void } const useInitialState = () => { // --- 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) => { if (data.id && data.status) { updateDownloadStatus(data.id, data.status, data.progress || 0); } }); return () => { if (socketRef.current) { socketRef.current.disconnect(); } }; }, []); // --- END SOCKET.IO CLIENT --- // Map of downloadId -> {progress, status} const [downloads, setDownloads] = useState<{ [id: string]: { progress: number; status?: string } }>({}); 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)) const newTasks: Task[] = items.map(item => ({ mediaItem: item, action: 'download' as const, containerId })) if (containerId) { newTasks.push({ mediaItem: container, action: 'download' }) } 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 { const response = await fetch('/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) { patchMediaItem(mediaItem.Id, item => ({ ...item, offlineState: 'downloaded' })); } else { // The mediaItem object is saved so we modify it directly mediaItem.offlineState = 'downloaded' if (mediaItem.Type === BaseItemKind.Audio) { const isTranscoded = [128000, 192000, 256000, 320000].includes(playback.bitrate) const streamUrl = api.getStreamUrl(mediaItem.Id, playback.bitrate) if (isTranscoded) { const { playlist, ts } = await downloadTranscodedTrack(streamUrl, signal) await audioStorage.saveTrack(mediaItem.Id, { type: 'm3u8', timestamp: Date.now(), mediaItem, bitrate: playback.bitrate, playlist, ts, containerId: next.containerId, }) } else { const response = await fetch(streamUrl, { signal }) if (!response.ok) throw new Error(`HTTP ${response.status}`) const blob = await response.blob() await audioStorage.saveTrack(mediaItem.Id, { type: 'song', timestamp: Date.now(), bitrate: playback.bitrate, mediaItem, blob, containerId: next.containerId, }) } } else { await audioStorage.saveTrack(mediaItem.Id, { type: 'container', timestamp: Date.now(), bitrate: playback.bitrate, mediaItem, }) } patchMediaItem(mediaItem.Id, item => ({ ...item, offlineState: 'downloaded' })) } } } 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) if (action === 'download') { 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 // Helper function to map backend status values to frontend offlineState values const mapBackendStatusToFrontendState = (status: string | undefined): 'downloading' | 'downloaded' | 'deleting' | 'accepted' | 'processing' | 'finished' | 'uploading' | undefined => { switch (status) { case 'Downloading': case 'downloading': return 'downloading'; case 'Downloading...': return 'processing'; case 'Complete': return 'finished'; case 'downloaded': return 'downloaded'; case 'Failed': return undefined; case 'Accepted': case 'accepted': return 'accepted'; case 'Uploading': case 'uploading': return 'uploading'; default: return status as 'downloading' | 'downloaded' | 'deleting' | 'accepted' | 'processing' | 'finished' | 'uploading' | undefined; } }; // Function to update download status based on WebSocket updates const updateDownloadStatus = (downloadId: string, status: string | undefined, progress: number) => { setDownloads(prev => ({ ...prev, [downloadId]: { progress, status } })); // For Spotify downloads, we need to find the corresponding MediaItem // The downloadId for Spotify tracks is in the format "spotify_{spotifyId}" if (downloadId.startsWith('spotify_')) { // For Spotify tracks, we'll need to find the MediaItem by its Id // This is a bit tricky since we don't have a direct mapping // For now, we'll just log the update console.log(`Spotify download status update: ${downloadId} - ${status} (${progress}%)`) const offlineState = mapBackendStatusToFrontendState(status); console.log(`Mapped Spotify download status: ${status} -> ${offlineState}`); // TODO: Find the corresponding MediaItem for Spotify tracks and update its offlineState // This requires maintaining a mapping between Spotify IDs and MediaItems } else { // For regular downloads, the downloadId should match the MediaItem.Id const offlineState = mapBackendStatusToFrontendState(status); patchMediaItem(downloadId, item => ({ ...item, offlineState })) } } return { addToDownloads, removeFromDownloads, storageStats, refreshStorageStats, queueCount: queue.length, clearQueue, updateDownloadStatus, downloads, } } 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 } }