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.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 */}
{/* 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
{!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)}
/>
);
};