import React, { useEffect, useState, useCallback, useRef } from 'react'; import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react'; import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '../store/authStore'; import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; import { useOfflineStore } from '../store/offlineStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; import { writeFile } from '@tauri-apps/plugin-fs'; import { join } from '@tauri-apps/api/path'; import { showToast } from '../utils/toast'; const ALBUM_COUNT = 30; /** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */ const ALBUM_FETCH_OVERSHOOT = 100; /** Cap genre-union size before rating prefetch (avoids hundreds of `getArtist` calls). */ const GENRE_UNION_PREFILTER_CAP = 250; function sanitizeFilename(name: string): string { return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download'; } async function fetchByGenres(genres: string[]): Promise { const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); const seen = new Set(); const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; }); for (let i = union.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [union[i], union[j]] = [union[j], union[i]]; } const pool = union.slice(0, GENRE_UNION_PREFILTER_CAP); const filtered = await filterAlbumsByMixRatings(pool, getMixMinRatingsConfigFromAuth()); return filtered.slice(0, ALBUM_COUNT); } export default function RandomAlbums() { const { t } = useTranslation(); const auth = useAuthStore(); const musicLibraryFilterVersion = auth.musicLibraryFilterVersion; const mixMinRatingFilterEnabled = auth.mixMinRatingFilterEnabled; const mixMinRatingAlbum = auth.mixMinRatingAlbum; const mixMinRatingArtist = auth.mixMinRatingArtist; const serverId = auth.activeServerId ?? ''; const { downloadAlbum } = useOfflineStore(); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const [albums, setAlbums] = useState([]); const [loading, setLoading] = useState(true); const [selectedGenres, setSelectedGenres] = useState([]); const loadingRef = useRef(false); const filtered = selectedGenres.length > 0; const [selectionMode, setSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); }; const toggleSelect = useCallback((id: string) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); }; const selectedAlbums = albums.filter(a => selectedIds.has(a.id)); const handleDownloadZips = async () => { if (selectedAlbums.length === 0) return; const folder = auth.downloadFolder || await requestDownloadFolder(); if (!folder) return; let done = 0; for (const album of selectedAlbums) { showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info'); try { const blob = await fetch(buildDownloadUrl(album.id)).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); }); const path = await join(folder, `${sanitizeFilename(album.name)}.zip`); await writeFile(path, new Uint8Array(await blob.arrayBuffer())); done++; } catch (e) { console.error('ZIP download failed for', album.name, e); showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error'); } } showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info'); clearSelection(); }; const handleAddOffline = async () => { if (selectedAlbums.length === 0) return; let queued = 0; for (const album of selectedAlbums) { try { const detail = await getAlbum(album.id); downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId); queued++; } catch { showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error'); } } if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info'); clearSelection(); }; const load = useCallback(async (genres: string[]) => { if (loadingRef.current) return; loadingRef.current = true; setLoading(true); try { const mixCfg = getMixMinRatingsConfigFromAuth(); const albumMixActive = mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0); const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT; const data = genres.length > 0 ? await fetchByGenres(genres) : (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT); setAlbums(data); } catch (e) { console.error(e); } finally { loadingRef.current = false; setLoading(false); } }, [ musicLibraryFilterVersion, mixMinRatingFilterEnabled, mixMinRatingAlbum, mixMinRatingArtist, ]); useEffect(() => { load(selectedGenres); }, [selectedGenres, load]); return (

{selectionMode && selectedIds.size > 0 ? t('albums.selectionCount', { count: selectedIds.size }) : t('randomAlbums.title')}

{selectionMode && selectedIds.size > 0 ? ( <> ) : ( <> )}
{loading && albums.length === 0 ? (
) : (
{albums.map(a => ( ))}
)}
); }