import { useState, useEffect, useRef, useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useSocket } from '../hooks/useSocket'; import { useDebounce } from '../hooks/useDebounce'; import { SearchIcon } from '../components/SvgIcons'; import { toast } from 'sonner'; import { useDownloadContext } from '../context/DownloadContext/DownloadContext'; import { MediaItem } from '../api/jellyfin'; import { MediaList } from '../components/MediaList'; import { DownloadDebugger } from '../components/DownloadDebugger'; import './OnlineSearch.css'; // Base path for saving downloads directly into Jellyfin library const MUSIC_BASE_PATH = (import.meta as any).env?.VITE_MUSIC_BASE_PATH || '/Volumes/Jellyfin Media/Music'; const sanitize = (name: string) => { if (!name) return 'Unknown'; return name .replace(/[\\/:*?"<>|]/g, '-') .replace(/\s+/g, ' ') .trim(); }; // Modal component for displaying detailed information const DetailModal = ({ item, onClose }: { item: SearchResult | null; onClose: () => void }) => { if (!item) return null; // Format duration helper const formatDuration = (ms: number) => { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; return (
e.stopPropagation()}> {/* Header with large artwork and main info */}
{item.name}

{item.name}

{item.artist}

{item.album &&

from {item.album}

} {/* Stats row */}
{item.duration && ( {formatDuration(item.duration)} )} {(item as any).popularity && ( {(item as any).popularity}/100 POPULARITY )} {(item as any).track_number && ( TRACK #{(item as any).track_number} )}
{/* Badges */}
TRACK {(item as any).explicit && EXPLICIT}
{/* Action buttons */}
🎵 OPEN IN SPOTIFY
{/* Album tracks section */} {item.type === 'track' && (

ALBUM TRACKS

#{(item as any).track_number || '1'}
{item.name} {item.duration ? formatDuration(item.duration) : '3:45'}

This modal will show all album tracks when connected to Spotify API. Currently showing selected track only.

)} {/* Preview section */} {(item as any).preview_url && (

PREVIEW

)}
); }; interface SearchResult { id: string; name: string; artist: string; album: string; image: string; type: 'track' | 'album' | 'playlist' | 'artist'; duration?: number; url: string; total_tracks?: number; release_date?: string; } interface DownloadStatus { status: string; progress?: number; id?: string; name?: string; } export const OnlineSearch = () => { const location = useLocation(); const [query, setQuery] = useState(''); const [searchType, setSearchType] = useState<'track' | 'album' | 'playlist' | 'artist'>('track'); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [downloadStatus, setDownloadStatus] = useState<{ [key: string]: DownloadStatus }>({}); // Map "name::artist" -> backend downloadId to bridge differing IDs between // Spotify search results and backend progress events const [downloadIdByName, setDownloadIdByName] = useState>({}); const [selectedItem, setSelectedItem] = useState(null); const { socket, isConnected } = useSocket(); const downloadContext = useDownloadContext(); const { downloads: contextDownloads } = downloadContext || { downloads: {} }; const containerRef = useRef(null); const searchTimeoutRef = useRef(null); // Function to detect and parse Spotify URLs const parseSpotifyUrl = (url: string) => { const spotifyUrlRegex = /https?:\/\/open\.spotify\.com\/(track|album|playlist|artist)\/([a-zA-Z0-9]+)/; const match = url.match(spotifyUrlRegex); if (match) { return { type: match[1] as 'track' | 'album' | 'playlist' | 'artist', id: match[2] }; } return null; }; const handleSearch = () => { if (!query.trim()) { toast.error('Please enter a search query.'); return; } if (!socket) { toast.error('Not connected to the server.'); return; } // Clear previous results and reset state to prevent search-after-download issues setResults([]); setLoading(true); if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); } // Force re-register search handlers to prevent download interference socket.off('search_results'); socket.off('spotify_lookup_result'); socket.on('search_results', (data: { results: SearchResult[] }) => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); searchTimeoutRef.current = null; } setResults(data.results || []); setLoading(false); }); socket.on('spotify_lookup_result', (data: { results: SearchResult[] }) => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); searchTimeoutRef.current = null; } setResults(data.results || []); setLoading(false); }); // Check if the query is a Spotify URL const spotifyUrlData = parseSpotifyUrl(query.trim()); if (spotifyUrlData) { // Auto-set the search type based on the URL setSearchType(spotifyUrlData.type); // For URLs, use a special direct lookup instead of search console.log('🔍 Emitting spotify_lookup:', { id: spotifyUrlData.id, type: spotifyUrlData.type }); socket.emit('spotify_lookup', { id: spotifyUrlData.id, type: spotifyUrlData.type }); } else { console.log('🔍 Emitting search:', { query, type: searchType }); socket.emit('search', { query, type: searchType }); } searchTimeoutRef.current = setTimeout(() => { setLoading(false); toast.error('Search timed out. Please try again.'); searchTimeoutRef.current = null; }, 10000); }; useEffect(() => { if (!socket) return; const handleSearchResults = async (data: { results: SearchResult[] }) => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); searchTimeoutRef.current = null; } // Check for existing tracks in the library if (data.results && data.results.length > 0) { try { const trackResults = data.results.filter(r => r.type === 'track'); if (trackResults.length > 0) { const backendUrl = window.location.hostname === 'music.samcloud.ca' ? '' : (import.meta.env.VITE_BACKEND_URL || 'http://localhost:5140'); const response = await fetch(`${backendUrl}/api/check-existing-tracks`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ tracks: trackResults.map(track => ({ id: track.id, name: track.name, artist: track.artist, album: track.album })) }) }); if (response.ok) { const existingData = await response.json(); if (existingData.success) { // Update results with existing track info const updatedResults = data.results.map(result => { if (result.type === 'track' && existingData.existing_tracks[result.id]) { return { ...result, existingTrackInfo: existingData.existing_tracks[result.id] }; } return result; }); setResults(updatedResults); setLoading(false); return; } } } } catch (error) { console.error('Error checking existing tracks:', error); } } setResults(data.results || []); setLoading(false); }; const handleSearchError = (error: any) => { if (searchTimeoutRef.current) { clearTimeout(searchTimeoutRef.current); searchTimeoutRef.current = null; } toast.error(error.message || 'An error occurred during search.'); setLoading(false); }; const handleDownloadUpdate = (data: any) => { console.log('🔄 OnlineSearch download update received:', data); // Handle the backend's data structure: {history: [array_of_downloads]} const downloads = data.history && Array.isArray(data.history) ? data.history : [data]; console.log(`📦 Processing ${downloads.length} download updates`); downloads.forEach((download: any) => { if (download.id && download.status) { console.log(`📥 OnlineSearch updating download ${download.id}: ${download.status} (${download.progress || 0}%)`); setDownloadStatus(prev => ({ ...prev, [download.id]: { status: download.status, progress: download.progress || 0, id: download.id, name: download.name } })); // Keep a mapping from name+artist -> download id to resolve status // even if the server uses a different id than the search result if (download.name) { const matched = results.find(r => r.name === download.name); // Try to be a tad stricter if we can const key = matched ? `${matched.name}::${matched.artist || ''}` : `${download.name}::`; setDownloadIdByName(prev => ({ ...prev, [key]: download.id })); } // Update the MediaItem offlineState in DownloadContext if (downloadContext && downloadContext.updateDownloadStatus) { // Map the status to the offlineState values let offlineState: string | undefined = 'downloading'; switch (download.status) { case 'Downloading': case 'downloading': offlineState = 'downloading'; break; case 'Downloading...': offlineState = 'processing'; break; case 'Complete': offlineState = 'finished'; break; case 'downloaded': offlineState = 'downloaded'; break; case 'Failed': offlineState = undefined; break; case 'Accepted': case 'accepted': offlineState = 'accepted'; break; case 'Uploading': case 'uploading': offlineState = 'uploading'; break; default: offlineState = download.status; } // Call updateDownloadStatus with the download ID and status downloadContext.updateDownloadStatus(download.id, offlineState, download.progress || 0); } // Show live progress updates in toast for better feedback if (download.status === 'Downloading...' && download.progress) { // Only show progress updates every 10% to avoid spam const progress = Math.floor(download.progress); if (progress % 10 === 0 || progress === 100) { toast.info(`${download.name || 'Item'}: ${progress}% downloaded`, { duration: 2000, position: 'bottom-center' }); } } else if (download.status === 'Complete') { toast.success(`${download.name || 'Item'} downloaded successfully!`, { position: 'bottom-center' }); } else if (download.status === 'Failed') { toast.error(`${download.name || 'Item'} failed to download.`, { position: 'bottom-center' }); } } }); }; socket.on('search_results', handleSearchResults); socket.on('spotify_lookup_result', handleSearchResults); // Handle direct lookup results the same way socket.on('search_error', handleSearchError); socket.on('spotify_lookup_error', handleSearchError); // Handle lookup errors the same way socket.on('update_status', handleDownloadUpdate); // Album download event listeners socket.on('album_download_started', (data: any) => { console.log('🎵 Album download started:', data); toast.success(data.message || `Started downloading album "${data.album_name}"`); }); return () => { socket.off('search_results', handleSearchResults); socket.off('spotify_lookup_result', handleSearchResults); socket.off('search_error', handleSearchError); socket.off('spotify_lookup_error', handleSearchError); socket.off('update_status', handleDownloadUpdate); socket.off('album_download_started'); }; }, [socket]); const debouncedSearch = useDebounce(handleSearch, 500); // Initialize query from URL (?q=) on mount and when it changes useEffect(() => { const params = new URLSearchParams(location.search); const q = params.get('q') || ''; // Only update if different to avoid loops if (q !== query) { setQuery(q); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [location.search]); useEffect(() => { if (query.trim() !== '') { debouncedSearch(); } else { setResults([]); setLoading(false); } }, [query, searchType]); // Remove debouncedSearch to prevent infinite loops // Custom download handler for Spotify tracks const handleSpotifyDownload = (item: MediaItem) => { if (!socket) { toast.error('Not connected to the server.'); return; } const originalResult = (item as any).searchResult; if (!originalResult) { toast.error('Invalid search result.'); return; } console.log('🔽 Starting Spotify download for:', originalResult); toast.info(`Downloading ${originalResult.name}...`); // Build target path: Artist/Album under base path (fallback to just Artist) const artistFolder = sanitize(originalResult.artist || 'Unknown Artist'); const albumFolder = sanitize(originalResult.album || ''); const targetPath = albumFolder ? `${MUSIC_BASE_PATH}/${artistFolder}/${albumFolder}` : `${MUSIC_BASE_PATH}/${artistFolder}`; // Use the Spotify-specific download endpoint socket.emit('download_spotify', { spotifyId: originalResult.id, trackName: originalResult.name, artistName: originalResult.artist }); // Update local download status for immediate UI feedback // Backend creates keys like "spotify_", so we need to handle both formats const originalKey = originalResult.id || originalResult.url; const backendKey = `spotify_${originalResult.id}`; const downloadInfo = { status: 'Queued...', progress: 0, id: backendKey, // Use backend format for consistency name: originalResult.name }; setDownloadStatus(prevStatus => ({ ...prevStatus, [originalKey]: downloadInfo, // Keep original for UI compatibility [backendKey]: downloadInfo // Add backend format for socket updates })); // Also update the MediaItem's offlineState immediately for UI feedback if (downloadContext && downloadContext.updateDownloadStatus) { downloadContext.updateDownloadStatus(backendKey, 'accepted', 0); } }; // Universal download handler that works for both tracks and albums const handleUniversalDownload = (item: MediaItem) => { if (!socket) { toast.error('Not connected to the server.'); return; } const originalResult = (item as any).searchResult; if (!originalResult) { toast.error('Invalid search result.'); return; } // Handle album downloads if (originalResult.type === 'album') { console.log('🎵 Starting album download for:', originalResult.name, 'ID:', originalResult.id); socket.emit('download_spotify_album', { albumId: originalResult.id, albumName: originalResult.name }); toast.info(`Started downloading album "${originalResult.name}"`); return; } // Handle track downloads (existing logic) console.log('🔽 Starting Spotify download for:', originalResult); toast.info(`Downloading ${originalResult.name}...`); // Build target path: Artist/Album under base path (fallback to just Artist) const artistFolder = sanitize(originalResult.artist || 'Unknown Artist'); const albumFolder = sanitize(originalResult.album || ''); const targetPath = albumFolder ? `${MUSIC_BASE_PATH}/${artistFolder}/${albumFolder}` : `${MUSIC_BASE_PATH}/${artistFolder}`; // Use the Spotify-specific download endpoint socket.emit('download_spotify', { spotifyId: originalResult.id, trackName: originalResult.name, artistName: originalResult.artist }); // Update local download status for immediate UI feedback // Backend creates keys like "spotify_", so we need to handle both formats const originalKey = originalResult.id || originalResult.url; const backendKey = `spotify_${originalResult.id}`; const downloadInfo = { status: 'Queued...', progress: 0, id: backendKey, // Use backend format for consistency name: originalResult.name }; setDownloadStatus(prevStatus => ({ ...prevStatus, [originalKey]: downloadInfo, // Keep original for UI compatibility [backendKey]: downloadInfo // Add backend format for socket updates })); // Also update the MediaItem's offlineState immediately for UI feedback if (downloadContext && downloadContext.updateDownloadStatus) { downloadContext.updateDownloadStatus(backendKey, 'accepted', 0); } }; // Convert search results to MediaItem format for MediaList component const convertToMediaItems = (searchResults: SearchResult[]): MediaItem[] => { return searchResults.map(result => { const downloadKey = result.id || result.url; const backendKey = `spotify_${result.id}`; // Backend format const currentDownloadStatus = downloadStatus[downloadKey]; const backendDownloadStatus = downloadStatus[backendKey]; // Check backend format const contextDownloadStatus = contextDownloads[downloadKey]; const contextBackendStatus = contextDownloads[backendKey]; // Check backend format in context // Try resolving via mapped id if backend uses a different id const nameArtistKey = `${result.name}::${result.artist || ''}`; const mappedId = downloadIdByName[nameArtistKey]; const mappedStatus = mappedId ? (downloadStatus[mappedId] || contextDownloads[mappedId]) : undefined; // Use context status first, then local, then backend format, then mappedId fallback let activeStatus = contextDownloadStatus || contextBackendStatus || currentDownloadStatus || backendDownloadStatus || mappedStatus; // Final fallback: try to find a status by matching name (best-effort) if (!activeStatus) { const byName = Object.values(downloadStatus).find(s => s?.name === result.name); if (byName) activeStatus = byName as any; } // Map download status to offlineState let offlineState: MediaItem['offlineState'] = undefined; if (activeStatus) { const status = activeStatus.status || activeStatus; switch (status) { case 'Downloading...': case 'Downloading': case 'downloading': offlineState = 'downloading'; break; case 'Complete': offlineState = 'finished'; break; case 'downloaded': offlineState = 'downloaded'; break; case 'Failed': offlineState = undefined; break; case 'Accepted': case 'accepted': offlineState = 'accepted'; break; case 'Uploading': case 'uploading': offlineState = 'uploading'; break; default: offlineState = undefined; } } return { Id: result.id || result.url, // Use URL as fallback ID Name: result.name, Type: result.type === 'album' ? 'MusicAlbum' : 'Audio', // Map album type correctly ServerId: 'spotify-search', RunTimeTicks: result.duration ? result.duration * 10000 : 0, IsFolder: result.type === 'album', MediaType: result.type === 'album' ? 'Album' : 'Audio', ChildCount: result.total_tracks, // For albums, show track count // Use both AlbumArtist and Artists for compatibility AlbumArtist: result.artist || 'Unknown Artist', Artists: result.artist ? [result.artist] : ['Unknown Artist'], Album: result.album || 'Unknown Album', UserData: { Played: false, IsFavorite: false }, // Spotify-specific fields for Discover mode SpotifyId: result.id, SpotifyUrl: result.url, SpotifyImage: result.image, // Store original search result data for download searchResult: result, // Add download status from our local state offlineState, downloadProgress: activeStatus?.progress || 0 } as MediaItem; }); }; const mediaItems = useMemo(() => convertToMediaItems(results), [results, downloadStatus, contextDownloads]); return (
{/* Search Header Section */}
Search & Download
Find and add music from Spotify to your library
setQuery(e.target.value)} placeholder="Search for music..." className="search-input" onKeyDown={(e) => e.key === 'Enter' && handleSearch()} />
{(['track', 'album', 'artist', 'playlist'] as const).map((type) => ( ))}
{!isConnected && (
Not connected to the server. Please check your connection.
)}
{/* Search Results Section - Styled like Home page */} {query.trim() !== '' && (
{loading ? 'Searching...' : `Search Results for "${query}"`}
{loading ? 'Finding music...' : results.length > 0 ? `Found ${results.length} results` : 'No results found'}
{loading ? (
) : results.length > 0 ? ( ) : (

No results found for "{query}". Try a different search term.

)}
)} setSelectedItem(null)} />
); };