import { useState, useEffect, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { useJellyfinContext } from '../context/JellyfinContext/JellyfinContext'; import { usePageTitle } from '../context/PageTitleContext/PageTitleContext'; import { MediaItem } from '../api/jellyfin'; import { Loader } from '../components/Loader'; import './Genres.css'; import { GENRE_TAXONOMY, TopGenre, TOP_BY_KEY } from '../data/genreTaxonomy'; import { buildGenreIndex, GenreIndex } from '../utils/genreIndex'; interface Genre { Id: string; Name: string; Type: string; UserData?: { UnplayedItemCount: number; }; IsMainGenre?: boolean; IsSubGenre?: boolean; HasSubGenres?: boolean; ParentGenre?: string; } interface GenreSection { mainGenre: Genre; subGenres: Genre[]; } export const Genres = () => { const { setPageTitle } = usePageTitle(); const api = useJellyfinContext(); const [genreSections, setGenreSections] = useState([]); const [fallbackGenres, setFallbackGenres] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [useHierarchical, setUseHierarchical] = useState(true); const [genreIndex, setGenreIndex] = useState(null); useEffect(() => { setPageTitle('Genres'); }, [setPageTitle]); useEffect(() => { const fetchGenres = async () => { setLoading(true); setError(null); try { // Try hierarchical genre endpoint first console.log('Fetching hierarchical genres from backend API...'); const response = await fetch('/api/jellyfin/genres'); if (response.ok) { const data = await response.json(); const genres: Genre[] = data.genres || []; console.log(`Fetched ${genres.length} genres from backend API`); // Check if we have hierarchical data const hasHierarchicalData = genres.some(g => g.IsMainGenre || g.IsSubGenre); if (hasHierarchicalData && genres.length > 0) { // Organize genres into hierarchical sections const genreMap = new Map(); // First pass: create sections for main genres genres.forEach(genre => { if (genre.IsMainGenre) { genreMap.set(genre.Name, { mainGenre: genre, subGenres: [] }); } }); // Second pass: add sub-genres to their parent main genres genres.forEach(genre => { if (genre.IsSubGenre && genre.ParentGenre) { const section = genreMap.get(genre.ParentGenre); if (section) { section.subGenres.push(genre); } } }); // Convert map to array and sort by main genre popularity const sectionsArray = Array.from(genreMap.values()).sort((a, b) => { const aCount = a.mainGenre.UserData?.UnplayedItemCount || 0; const bCount = b.mainGenre.UserData?.UnplayedItemCount || 0; return bCount - aCount; }); // Sort sub-genres within each section by popularity sectionsArray.forEach(section => { section.subGenres.sort((a, b) => { const aCount = a.UserData?.UnplayedItemCount || 0; const bCount = b.UserData?.UnplayedItemCount || 0; return bCount - aCount; }); }); console.log(`Organized into ${sectionsArray.length} genre sections`); setGenreSections(sectionsArray); setUseHierarchical(true); // keep loading true until we also try to build client-side union counts (non-blocking) setLoading(false); return; } } // Fallback to original implementation if hierarchical fails console.log('Falling back to original genre fetching...'); setUseHierarchical(false); let allGenres: MediaItem[] = []; // Try to get genres from music library directly with maximum limit try { console.log('Trying Jellyfin API searchGenres...'); const musicGenres = await api.searchGenres('', 2000); // Use max limit if (musicGenres && musicGenres.length > 0) { allGenres = musicGenres; console.log('Fetched genres via searchGenres:', allGenres.length); } } catch (searchError) { console.warn('searchGenres failed:', searchError); } // If still not enough genres, extract from all music items if (allGenres.length < 10) { try { console.log('Extracting genres from all music items...'); // Get all music items in batches to avoid memory issues const batchSize = 2000; let startIndex = 0; const genreSet = new Set(); const genreMap = new Map(); // Add existing genres to the set allGenres.forEach(genre => { if (genre.Name) { genreSet.add(genre.Name); genreMap.set(genre.Name, { count: genre.UserData?.UnplayedItemCount || 0, item: genre }); } }); let hasMoreItems = true; let totalProcessed = 0; while (hasMoreItems && totalProcessed < 20000) { // Safety limit try { const items: MediaItem[] = await api.getAllTracks(startIndex, batchSize); if (items && items.length > 0) { items.forEach((item: MediaItem) => { if (item.Genres && item.Genres.length > 0) { item.Genres.forEach((genre: string) => { if (genre && genre.trim()) { const cleanGenre = genre.trim(); if (!genreSet.has(cleanGenre)) { genreSet.add(cleanGenre); genreMap.set(cleanGenre, { count: 1, item: { Id: `genre-${cleanGenre}`, Name: cleanGenre, Type: 'Genre', UserData: { UnplayedItemCount: 1 } } as MediaItem }); } else if (genreMap.has(cleanGenre)) { const existing = genreMap.get(cleanGenre)!; existing.count++; if (existing.item.UserData) { existing.item.UserData.UnplayedItemCount = existing.count; } } } }); } }); totalProcessed += items.length; startIndex += batchSize; if (items.length < batchSize) { hasMoreItems = false; } } else { hasMoreItems = false; } } catch (batchError) { console.warn(`Error fetching batch starting at ${startIndex}:`, batchError); hasMoreItems = false; } } // Convert to final genre list const extractedGenres = Array.from(genreMap.values()).map(g => g.item); console.log(`Processed ${totalProcessed} music items, found ${extractedGenres.length} unique genres`); if (extractedGenres.length > allGenres.length) { allGenres = extractedGenres; } } catch (extractError) { console.warn('Genre extraction from music items failed:', extractError); } } // Remove duplicates and sort genres alphabetically const uniqueGenres = Array.from( new Map(allGenres.map(genre => [genre.Name, genre])).values() ).sort((a, b) => (a.Name || '').localeCompare(b.Name || '')); console.log('Final unique genres list:', uniqueGenres.length, uniqueGenres.slice(0, 10).map(g => g.Name)); setFallbackGenres(uniqueGenres); } catch (err) { console.error('Error fetching genres:', err); setError('Failed to load genres'); } finally { setLoading(false); } }; if (api.auth.token) { fetchGenres(); } }, [api]); // Build client-side genre index from entire library (non-blocking, best-effort) useEffect(() => { if (!api.auth.token) return; let cancelled = false; const run = async () => { try { console.log('Building client-side genre index...'); const batchSize = 2000; let startIndex = 0; const tracks: MediaItem[] = []; let seen = new Set(); let more = true; let safety = 0; while (more && safety < 50) { const items = await api.getAllTracks(startIndex, batchSize); for (const it of items) { if (it?.Id && !seen.has(it.Id)) { tracks.push(it); seen.add(it.Id); } } if (items.length < batchSize) more = false; else startIndex += batchSize; safety++; } if (cancelled) return; const idx = buildGenreIndex(tracks); if (!cancelled) setGenreIndex(idx); console.log('Genre index built:', { tops: Object.keys(idx.topIndex).length, subs: Object.keys(idx.subIndex).length }); } catch (e) { console.warn('Failed to build client-side genre index:', e); } }; run(); return () => { cancelled = true; }; }, [api.auth.token, api]); const handleGenreClick = (genre: Genre | MediaItem) => { console.log('Genre clicked:', genre.Name, 'Type:', genre.Type); // For hierarchical genres, navigate directly without React Router const genreName = encodeURIComponent(genre.Name || ''); window.location.href = `/genre/${genreName}`; }; // Helper: map taxonomy by display name for quick matching const taxonomyByName = useMemo(() => { const m = new Map(); GENRE_TAXONOMY.forEach(t => m.set(t.name, t.key)); return m; }, []); if (loading) { return ; } if (error) { return
{error}
; } // Render hierarchical layout if available if (useHierarchical && genreSections.length > 0) { return (

Music Genres

Browse your music by genre - click main genres for broad selection or sub-genres for specific styles

{genreSections.map((section) => { const rawName = section.mainGenre.Name || 'Unknown'; const displayName = rawName === 'Reggae' ? 'Caribbean' : rawName; const topKey = taxonomyByName.get(displayName); const topDef: TopGenre | undefined = topKey ? TOP_BY_KEY[topKey] : undefined; const unionCount = topKey && genreIndex ? genreIndex.counts.top[topKey] : undefined; // Build subgenre list: taxonomy-driven if available; otherwise backend-provided const taxonomySubs = (topDef?.subgenres || []).map(s => ({ key: s.key, name: s.name, count: genreIndex ? (genreIndex.counts.sub[s.key] || 0) : 0, })); const hasTaxonomySubsToShow = !!topDef; // show taxonomy-defined subs even if count is 0 // Local map: subgenre name (lower) -> key for this top genre, guarded by topDef const subNameToKey = (() => { const m = new Map(); if (topDef) { for (const s of topDef.subgenres) { m.set(s.name.toLowerCase(), s.key); } } return m; })(); return (
{/* Main Genre */}
handleGenreClick(section.mainGenre)} >

{displayName}

{(() => { if (unionCount != null) { const subCount = taxonomySubs.filter(s => s.count > 0).length; return `${unionCount} track${unionCount !== 1 ? 's' : ''}${subCount ? ` • ${subCount} sub-genres` : ''}`; } const c = section.mainGenre.UserData?.UnplayedItemCount || 0; const sg = section.subGenres.length; return `${c} track${c !== 1 ? 's' : ''}${sg ? ` • ${sg} sub-genres` : ''}`; })()}

{/* Sub-genres row */} {hasTaxonomySubsToShow ? (
{taxonomySubs.sort((a,b) => b.count - a.count).map((s) => (
handleGenreClick({ Id: s.key, Name: s.name, Type: 'MusicGenre' } as any)} >

{s.name}

{`${s.count} track${s.count !== 1 ? 's' : ''}`}

))}
) : ( section.subGenres.length > 0 && (
{section.subGenres.map((subGenre) => (
handleGenreClick(subGenre)} >

{subGenre.Name || 'Unknown'}

{(() => { if (genreIndex && topKey) { const subKey = subNameToKey.get((subGenre.Name || '').toLowerCase()); if (subKey && genreIndex.counts.sub[subKey] != null) { const c = genreIndex.counts.sub[subKey]; return `${c} track${c !== 1 ? 's' : ''}`; } } const c = subGenre.UserData?.UnplayedItemCount || 0; return `${c} track${c !== 1 ? 's' : ''}`; })()}

))}
) )}
); })}
); } // Fallback to original grid layout return (

Music Genres

Browse your music by genre

{fallbackGenres.length === 0 ? (

No genres found in your music library

) : (
{fallbackGenres.map((genre: MediaItem) => (

{genre.Name}

{genre.UserData?.UnplayedItemCount && genre.UserData.UnplayedItemCount > 0 && (

{genre.UserData.UnplayedItemCount} track{genre.UserData.UnplayedItemCount !== 1 ? 's' : ''}

)}
))}
)}
); };