import React, { useState, useEffect, useRef, Suspense, lazy, useMemo } from "react"; import { toast } from "react-toastify"; import { Button } from "@mui/joy"; import { Accordion, AccordionTab } from "primereact/accordion"; import { AutoComplete } from "primereact/autocomplete"; import { authFetch } from "@/utils/authFetch"; import BreadcrumbNav from "./BreadcrumbNav"; import { API_URL, ENVIRONMENT } from "@/config"; import "./MediaRequestForm.css"; export default function MediaRequestForm() { const [type, setType] = useState("artist"); const [selectedArtist, setSelectedArtist] = useState(null); const [artistInput, setArtistInput] = useState(""); const [albumInput, setAlbumInput] = useState(""); const [trackInput, setTrackInput] = useState(""); const [quality, setQuality] = useState("FLAC"); // default FLAC const [selectedItem, setSelectedItem] = useState(null); const [albums, setAlbums] = useState([]); const [tracksByAlbum, setTracksByAlbum] = useState({}); const [selectedTracks, setSelectedTracks] = useState({}); const [artistSuggestions, setArtistSuggestions] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [isSearching, setIsSearching] = useState(false); const [loadingAlbumId, setLoadingAlbumId] = useState(null); const [expandedAlbums, setExpandedAlbums] = useState([]); const [isFetching, setIsFetching] = useState(false); const debounceTimeout = useRef(null); const autoCompleteRef = useRef(null); const metadataFetchToastId = useRef(null); const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Helper for delays const Spinner = () => ( ); // Fetch artist suggestions for autocomplete const searchArtists = (e) => { const query = e.query.trim(); if (!query) { setArtistSuggestions([]); setSelectedArtist(null); return; } if (debounceTimeout.current) clearTimeout(debounceTimeout.current); debounceTimeout.current = setTimeout(async () => { try { // Ensure at least 600ms between actual requests const now = Date.now(); if (!searchArtists.lastCall) searchArtists.lastCall = 0; const elapsed = now - searchArtists.lastCall; const minDelay = 600; // ms if (elapsed < minDelay) await delay(minDelay - elapsed); searchArtists.lastCall = Date.now(); const res = await authFetch( `${API_URL}/trip/get_artists_by_name?artist=${encodeURIComponent(query)}&group=true` ); if (!res.ok) throw new Error("API error"); const data = await res.json(); setArtistSuggestions(data); } catch (err) { toast.error("Failed to fetch artist suggestions."); setArtistSuggestions([]); } }, 500); // debounce 500ms }; const truncate = (text, maxLen) => maxLen <= 3 ? text.slice(0, maxLen) : text.length <= maxLen ? text : text.slice(0, maxLen - 3) + '...'; const selectArtist = (artist) => { // artist may be a grouped item or an alternate object const value = artist.artist || artist.name || ""; setSelectedArtist(artist); setArtistInput(value); setArtistSuggestions([]); // Hide autocomplete panel and blur input to prevent leftover white box. // Use a small timeout so PrimeReact internal handlers can finish first. try { const ac = autoCompleteRef.current; // Give PrimeReact a tick to finish its events, then call hide(). setTimeout(() => { try { if (ac && typeof ac.hide === "function") { ac.hide(); } // If panel still exists, hide it via style (safer than removing the node) setTimeout(() => { const panel = document.querySelector('.p-autocomplete-panel'); if (panel) { panel.style.display = 'none'; panel.style.opacity = '0'; panel.style.visibility = 'hidden'; panel.style.pointerEvents = 'none'; } }, 10); // blur the input element if present const inputEl = ac?.getInput ? ac.getInput() : document.querySelector('.p-autocomplete-input'); if (inputEl && typeof inputEl.blur === 'function') inputEl.blur(); } catch (innerErr) { // Ignore inner errors } }, 0); } catch (err) { // Ignore outer errors } }; const totalAlbums = albums.length; const totalTracks = useMemo(() => Object.values(tracksByAlbum).reduce((sum, arr) => (Array.isArray(arr) ? sum + arr.length : sum), 0), [tracksByAlbum] ); const artistItemTemplate = (artist) => { if (!artist) return null; const alts = artist.alternatives || artist.alternates || []; return (
{truncate(artist.artist || artist.name, 58)} ID: {artist.id}
{alts.length > 0 && (
Alternates: {alts.map((alt, idx) => ( ID: {alt.id} {idx < alts.length - 1 ? , : null} ))}
)}
); }; // Handle autocomplete input changes (typing/selecting) const handleArtistChange = (e) => { if (typeof e.value === "string") { setArtistInput(e.value); setSelectedArtist(null); } else if (e.value && typeof e.value === "object") { setSelectedArtist(e.value); setArtistInput(e.value.artist); } else { setArtistInput(""); setSelectedArtist(null); } }; // Search button click handler const handleSearch = async () => { toast.dismiss(); setIsSearching(true); try { if (metadataFetchToastId.current) toast.dismiss(metadataFetchToastId.current); } catch (err) { } metadataFetchToastId.current = toast.info("Retrieving metadata...", { autoClose: false, progress: 0, closeOnClick: false, } ); if (type === "artist") { if (!selectedArtist) { toast.error("Please select a valid artist from suggestions."); setIsSearching(false); return; } setSelectedItem(selectedArtist.artist); try { const res = await authFetch( `${API_URL}/trip/get_albums_by_artist_id/${selectedArtist.id}?quality=${quality}` ); if (!res.ok) throw new Error("API error"); const data = await res.json(); data.sort((a, b) => (b.release_date || "").localeCompare(a.release_date || "") ); setAlbums(data); setTracksByAlbum({}); setExpandedAlbums([]); // Set selectedTracks for all albums as null (means tracks loading/not loaded) setSelectedTracks( data.reduce((acc, album) => { acc[album.id] = null; return acc; }, {}) ); } catch (err) { toast.error("Failed to fetch albums for artist."); setAlbums([]); setTracksByAlbum({}); setSelectedTracks({}); } } else if (type === "album") { if (!artistInput.trim() || !albumInput.trim()) { toast.error("Artist and Album are required."); setIsSearching(false); return; } setSelectedItem(`${artistInput} - ${albumInput}`); setAlbums([]); setTracksByAlbum({}); setSelectedTracks({}); } else if (type === "track") { if (!artistInput.trim() || !trackInput.trim()) { toast.error("Artist and Track are required."); setIsSearching(false); return; } setSelectedItem(`${artistInput} - ${trackInput}`); setAlbums([]); setTracksByAlbum({}); setSelectedTracks({}); } setIsSearching(false); }; const handleTrackClick = async (trackId, artist, title) => { try { const res = await authFetch(`${API_URL}/trip/get_track_by_id/${trackId}?quality=${quality}`); if (!res.ok) throw new Error("Failed to fetch track URL"); const data = await res.json(); if (data.stream_url) { // Use plain fetch for public resource const fileResponse = await fetch(data.stream_url, { method: "GET", mode: "cors", // ensure cross-origin is allowed credentials: "omit", // do NOT send cookies or auth }); if (!fileResponse.ok) { throw new Error(`Failed to fetch track file: ${fileResponse.status}`); } const blob = await fileResponse.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; const sanitize = (str) => str.replace(/[\\/:*?"<>|]/g, "_"); const urlPath = new URL(data.stream_url).pathname; const extension = urlPath.split('.').pop().split('?')[0] || 'flac'; const filename = `${sanitize(artist)} - ${sanitize(title)}.${extension}`; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } else { toast.error("No stream URL returned for this track."); } } catch (error) { toast.error("Failed to get track download URL."); console.error(error); } }; const allTracksLoaded = albums.every(({ id }) => Array.isArray(tracksByAlbum[id]) && tracksByAlbum[id].length > 0); const handleToggleAllAlbums = () => { const allSelected = albums.every(({ id }) => { const allTracks = tracksByAlbum[id] || []; return selectedTracks[id]?.length === allTracks.length && allTracks.length > 0; }); const newSelection = {}; albums.forEach(({ id }) => { const allTracks = tracksByAlbum[id] || []; if (allSelected) { // Uncheck all newSelection[id] = []; } else { // Check all tracks in the album newSelection[id] = allTracks.map(track => String(track.id)); } }); setSelectedTracks(newSelection); }; { e.preventDefault(); if (!allTracksLoaded) return; // prevent clicking before data ready handleToggleAllAlbums(); }} className={`text-sm hover:underline cursor-pointer ${!allTracksLoaded ? "text-gray-400 dark:text-gray-500 pointer-events-none" : "text-blue-600" }`} > Check / Uncheck All Albums // Sequentially fetch tracks for albums not loaded yet useEffect(() => { if (type !== "artist" || albums.length === 0) return; let isCancelled = false; const albumsToFetch = albums.filter((a) => !tracksByAlbum[a.id]); if (albumsToFetch.length === 0) return; const fetchTracksSequentially = async () => { const minDelay = 650; // ms between API requests setIsFetching(true); const totalAlbums = albumsToFetch.length; for (let index = 0; index < totalAlbums; index++) { const album = albumsToFetch[index]; if (isCancelled) break; setLoadingAlbumId(album.id); try { const now = Date.now(); if (!fetchTracksSequentially.lastCall) fetchTracksSequentially.lastCall = 0; const elapsed = now - fetchTracksSequentially.lastCall; if (elapsed < minDelay) await delay(minDelay - elapsed); fetchTracksSequentially.lastCall = Date.now(); const res = await authFetch(`${API_URL}/trip/get_tracks_by_album_id/${album.id}`); if (!res.ok) throw new Error("API error"); const data = await res.json(); if (isCancelled) break; setTracksByAlbum((prev) => ({ ...prev, [album.id]: data })); setSelectedTracks((prev) => ({ ...prev, [album.id]: data.map((t) => t.id), })); } catch (err) { toast.error(`Failed to fetch tracks for album ${album.album}.`); setTracksByAlbum((prev) => ({ ...prev, [album.id]: [] })); setSelectedTracks((prev) => ({ ...prev, [album.id]: [] })); } // Update progress toast toast.update(metadataFetchToastId.current, { progress: (index + 1) / totalAlbums, render: `Retrieving metadata... (${index + 1} / ${totalAlbums})`, }); } setLoadingAlbumId(null); setIsFetching(false); // Finish the toast toast.update(metadataFetchToastId.current, { render: "Metadata retrieved!", type: "success", progress: 1, autoClose: 1500, }); }; fetchTracksSequentially(); return () => { isCancelled = true; }; }, [albums, type]); // Toggle individual track checkbox const toggleTrack = (albumId, trackId) => { setSelectedTracks((prev) => { const current = new Set(prev[albumId] || []); if (current.has(String(trackId))) current.delete(String(trackId)); else current.add(String(trackId)); return { ...prev, [albumId]: Array.from(current) }; }); }; // Toggle album checkbox (select/deselect all tracks in album) const toggleAlbum = (albumId) => { const allTracks = tracksByAlbum[albumId]?.map((t) => String(t.id)) || []; setSelectedTracks((prev) => { const current = prev[albumId] || []; const allSelected = current.length === allTracks.length; return { ...prev, [albumId]: allSelected ? [] : [...allTracks], }; }); }; // Attach scroll fix for autocomplete panel const attachScrollFix = () => { setTimeout(() => { const panel = document.querySelector(".p-autocomplete-panel"); const items = panel?.querySelector(".p-autocomplete-items"); if (items) { items.style.maxHeight = "200px"; items.style.overflowY = "auto"; items.style.overscrollBehavior = "contain"; const wheelHandler = (e) => { const delta = e.deltaY; const atTop = items.scrollTop === 0; const atBottom = items.scrollTop + items.clientHeight >= items.scrollHeight; if ((delta < 0 && atTop) || (delta > 0 && atBottom)) { e.preventDefault(); } else { e.stopPropagation(); } }; items.removeEventListener("wheel", wheelHandler); items.addEventListener("wheel", wheelHandler, { passive: false }); } }, 0); }; // Submit request handler with progress indicator const handleSubmitRequest = async () => { if (isFetching) { // tracks are not done being fetched return toast.error("Still fetching track metadata, please wait a moment."); } setIsSubmitting(true); try { const allSelectedIds = Object.values(selectedTracks) .filter(arr => Array.isArray(arr)) // skip null entries .flat(); const response = await authFetch(`${API_URL}/trip/bulk_fetch`, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify({ track_ids: allSelectedIds, target: selectedArtist.artist, quality: quality, }), }); if (!response.ok) { throw new Error(`Server error: ${response.status}`); } const data = await response.json(); toast.success(`Request submitted! (${allSelectedIds.length} tracks)`); } catch (err) { console.error(err); toast.error("Failed to submit request."); } finally { setIsSubmitting(false); } }; return (

New Request

{(type === "album" || type === "track") && ( type === "album" ? setAlbumInput(e.target.value) : setTrackInput(e.target.value) } placeholder={type === "album" ? "Album" : "Track"} /> )}
{type === "artist" && albums.length > 0 && ( <>
Albums: {totalAlbums} | Tracks: {totalTracks}
setExpandedAlbums(e.index)} > {albums.map(({ album, id, release_date }) => { const allTracks = tracksByAlbum[id] || []; const selected = selectedTracks[id]; // Album checkbox is checked if tracks not loaded (selected === null) // or all tracks loaded and all selected const allChecked = selected === null || (selected?.length === allTracks.length && allTracks.length > 0); const someChecked = selected !== null && selected.length > 0 && selected.length < allTracks.length; return ( { if (el) el.indeterminate = someChecked; }} onChange={() => toggleAlbum(id)} onClick={(e) => e.stopPropagation()} className="cursor-pointer" aria-label={`Select all tracks for album ${album}`} /> {truncate(album, 32)} {loadingAlbumId === id && } ({release_date}) {typeof tracksByAlbum[id] === 'undefined' ? ( loadingAlbumId === id ? 'Loading...' : '...' ) : ( `${allTracks.length} track${allTracks.length !== 1 ? 's' : ''}` )}
} > {allTracks.length > 0 ? (
    {allTracks.map((track) => (
  • toggleTrack(id, track.id)} className="cursor-pointer" aria-label={`Select track ${track.title} `} /> {quality} {track.version && ( ({track.version}) )} {track.duration && ( {track.duration} )}
  • ))}
) : (
{tracksByAlbum[id] ? "No tracks found for this album." : "Loading tracks..."}
)} ); })}
) }
); }