diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa8fbac..c854bb9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,5 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Progress } from "@/components/ui/progress"; -import { Badge } from "@/components/ui/badge"; import { Dialog, DialogContent, @@ -14,69 +8,46 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { fetchSpotifyMetadata, downloadTrack } from "@/lib/api"; -import type { SpotifyMetadataResponse, TrackMetadata } from "@/types/api"; -import { Settings } from "@/components/Settings"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Search } from "lucide-react"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { getSettings, applyThemeMode } from "@/lib/settings"; import { applyTheme } from "@/lib/themes"; -import { Download, Search, CheckCircle, Info, XCircle, ArrowUpDown, StopCircle, FolderOpen } from "lucide-react"; -import { toastWithSound as toast } from "@/lib/toast-with-sound"; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "@/components/ui/pagination"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { Spinner } from "@/components/ui/spinner"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { joinPath, sanitizePath } from "./lib/utils"; import { OpenFolder } from "../wailsjs/go/main/App"; +import { toastWithSound as toast } from "@/lib/toast-with-sound"; + +// Components +import { Header } from "@/components/Header"; +import { SearchBar } from "@/components/SearchBar"; +import { TrackInfo } from "@/components/TrackInfo"; +import { AlbumInfo } from "@/components/AlbumInfo"; +import { PlaylistInfo } from "@/components/PlaylistInfo"; +import { ArtistInfo } from "@/components/ArtistInfo"; + +// Hooks +import { useDownload } from "@/hooks/useDownload"; +import { useMetadata } from "@/hooks/useMetadata"; function App() { const [spotifyUrl, setSpotifyUrl] = useState(""); - const [loading, setLoading] = useState(false); - const [metadata, setMetadata] = useState(null); const [selectedTracks, setSelectedTracks] = useState([]); const [searchQuery, setSearchQuery] = useState(""); - const [downloadProgress, setDownloadProgress] = useState(0); - const [isDownloading, setIsDownloading] = useState(false); - const [downloadingTrack, setDownloadingTrack] = useState(null); - const [bulkDownloadType, setBulkDownloadType] = useState<'all' | 'selected' | null>(null); - const [downloadedTracks, setDownloadedTracks] = useState>(new Set()); - const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{ name: string; artists: string } | null>(null); - const [showTimeoutDialog, setShowTimeoutDialog] = useState(false); - const [timeoutValue, setTimeoutValue] = useState(60); - const [pendingUrl, setPendingUrl] = useState(""); + const [sortBy, setSortBy] = useState("default"); const [currentPage, setCurrentPage] = useState(1); const [hasUpdate, setHasUpdate] = useState(false); - const [showAlbumDialog, setShowAlbumDialog] = useState(false); - const [selectedAlbum, setSelectedAlbum] = useState<{ id: string; name: string; external_urls: string } | null>(null); - const [sortBy, setSortBy] = useState("default"); - const shouldStopDownloadRef = useRef(false); - + const ITEMS_PER_PAGE = 50; - const CURRENT_VERSION = "5.6"; + const CURRENT_VERSION = "5.7"; + + const download = useDownload(); + const metadata = useMetadata(); useEffect(() => { const settings = getSettings(); applyThemeMode(settings.themeMode); applyTheme(settings.theme); - // Listen for system theme changes when in auto mode const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleChange = () => { const currentSettings = getSettings(); @@ -87,8 +58,6 @@ function App() { }; mediaQuery.addEventListener("change", handleChange); - - // Check for updates checkForUpdates(); return () => { @@ -96,382 +65,50 @@ function App() { }; }, []); + useEffect(() => { + setSelectedTracks([]); + setSearchQuery(""); + download.resetDownloadedTracks(); + setSortBy("default"); + setCurrentPage(1); + }, [metadata.metadata]); + const checkForUpdates = async () => { try { - const response = await fetch('https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/version.json'); + const response = await fetch( + "https://raw.githubusercontent.com/afkarxyz/SpotiFLAC/refs/heads/main/version.json" + ); const data = await response.json(); const latestVersion = data.version; - - // Compare versions (simple string comparison works for x.y format) + if (latestVersion > CURRENT_VERSION) { setHasUpdate(true); } } catch (err) { - // Silently fail if update check fails - console.error('Failed to check for updates:', err); + console.error("Failed to check for updates:", err); } }; - useEffect(() => { - // Clear selection, search, downloaded tracks, sort, and reset page when metadata changes - setSelectedTracks([]); - setSearchQuery(""); - setDownloadedTracks(new Set()); - setSortBy("default"); - setCurrentPage(1); - }, [metadata]); - - const downloadWithAutoFallback = async ( - isrc: string, - settings: any, - trackName?: string, - artistName?: string, - albumName?: string, - playlistName?: string, - isArtistDiscography?: boolean - ) => { - let service = settings.downloader; - - // Build query for Tidal (title + artist) - const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; - - // Build output directory based on settings - const os = settings.operatingSystem; - - // Base download path - let outputDir = settings.downloadPath; - - // For playlist or artist discography downloads - - // Playlist or discography - if (playlistName) { - outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os)); - - if (isArtistDiscography) { - // Only album subfolder - if (settings.albumSubfolder && albumName) { - outputDir = joinPath(os, outputDir, sanitizePath(albumName, os)); - } - } else { - // Playlist rules: - if (settings.artistSubfolder && artistName) { - outputDir = joinPath(os, outputDir, sanitizePath(artistName, os)); - } - - if (settings.albumSubfolder && albumName) { - outputDir = joinPath(os, outputDir, sanitizePath(albumName, os)); - } - } - } - - // If auto mode, try Tidal first - if (service === "auto") { - try { - const tidalResponse = await downloadTrack({ - isrc, - service: "tidal", - query, - output_dir: outputDir, - filename_format: settings.filenameFormat, - track_number: settings.trackNumber, - }); - - if (tidalResponse.success) { - return tidalResponse; - } - - // Tidal failed, try Deezer - service = "deezer"; - } catch (tidalErr) { - service = "deezer"; - } - } - - // Use selected service or fallback to Deezer - return await downloadTrack({ - isrc, - service: service as "deezer" | "tidal", - query, - output_dir: outputDir, - filename_format: settings.filenameFormat, - track_number: settings.trackNumber, - }); - }; - const handleFetchMetadata = async () => { - if (!spotifyUrl.trim()) { - toast.error("Please enter a Spotify URL"); - return; - } - - let urlToFetch = spotifyUrl.trim(); - const isArtistUrl = urlToFetch.includes('/artist/'); - - // Auto-convert artist URL to discography - if (isArtistUrl && !urlToFetch.includes('/discography')) { - urlToFetch = urlToFetch.replace(/\/$/, '') + '/discography/all'; - setSpotifyUrl(urlToFetch); - } - - // Show timeout dialog only for artist URLs - if (isArtistUrl) { - setPendingUrl(urlToFetch); - setShowTimeoutDialog(true); - } else { - // Directly fetch for non-artist URLs (track, album, playlist) - await fetchMetadataDirectly(urlToFetch); + const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl); + if (updatedUrl) { + setSpotifyUrl(updatedUrl); } }; - const fetchMetadataDirectly = async (url: string) => { - setLoading(true); - setMetadata(null); - - try { - const data = await fetchSpotifyMetadata(url); - setMetadata(data); - toast.success("Metadata fetched successfully"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to fetch metadata"); - } finally { - setLoading(false); - } - }; - - const handleConfirmFetch = async () => { - setShowTimeoutDialog(false); - setLoading(true); - setMetadata(null); - - try { - const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue); - setMetadata(data); - toast.success("Metadata fetched successfully"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to fetch metadata"); - } finally { - setLoading(false); - } - }; - - const handleAlbumClick = (album: { id: string; name: string; external_urls: string }) => { - setSelectedAlbum(album); - setShowAlbumDialog(true); - }; - - const handleConfirmAlbumFetch = async () => { - if (!selectedAlbum) return; - - setShowAlbumDialog(false); - setLoading(true); - setMetadata(null); - - try { - const data = await fetchSpotifyMetadata(selectedAlbum.external_urls); - setMetadata(data); - toast.success("Album metadata fetched successfully"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to fetch album metadata"); - } finally { - setLoading(false); - setSelectedAlbum(null); - } - }; - - const handleDownloadTrack = async (isrc: string, trackName?: string, artistName?: string, albumName?: string) => { - if (!isrc) { - toast.error("No ISRC found for this track"); - return; - } - - const settings = getSettings(); - setDownloadingTrack(isrc); - - try { - // Single track download - no playlist folder - const response = await downloadWithAutoFallback(isrc, settings, trackName, artistName, albumName, undefined, false); - - if (response.success) { - toast.success(response.message); - setDownloadedTracks(prev => new Set(prev).add(isrc)); - } else { - toast.error(response.error || "Download failed"); - } - } catch (err) { - toast.error(err instanceof Error ? err.message : "Download failed"); - } finally { - setDownloadingTrack(null); - } - }; - - const handleDownloadSelected = async () => { - if (selectedTracks.length === 0) { - toast.error("No tracks selected"); - return; - } - - const settings = getSettings(); - setIsDownloading(true); - setBulkDownloadType('selected'); - setDownloadProgress(0); - - let successCount = 0; - let errorCount = 0; - const total = selectedTracks.length; - - // Get all tracks and playlist/album info from metadata - let allTracks: TrackMetadata[] = []; - let playlistName: string | undefined; - let isArtistDiscography = false; - - if (metadata && "track_list" in metadata) { - allTracks = metadata.track_list; - - // Get playlist/album name for folder structure - if ("album_info" in metadata) { - playlistName = metadata.album_info.name; - } else if ("playlist_info" in metadata) { - playlistName = metadata.playlist_info.owner.name; - } else if ("artist_info" in metadata) { - playlistName = metadata.artist_info.name; - isArtistDiscography = true; - } - } - - for (let i = 0; i < selectedTracks.length; i++) { - // Check if user clicked Stop - if (shouldStopDownloadRef.current) { - toast.info(`Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.`); - break; - } - - const isrc = selectedTracks[i]; - const track = allTracks.find(t => t.isrc === isrc); - - setDownloadingTrack(isrc); // Show spinner on this track - - // Set current download info for progress display - if (track) { - setCurrentDownloadInfo({ name: track.name, artists: track.artists }); - } - - try { - const response = await downloadWithAutoFallback( - isrc, - settings, - track?.name, - track?.artists, - track?.album_name, - playlistName, - isArtistDiscography - ); - - if (response.success) { - successCount++; - setDownloadedTracks(prev => new Set(prev).add(isrc)); - } else { - errorCount++; - } - } catch (err) { - errorCount++; - } - - setDownloadProgress(Math.round(((i + 1) / total) * 100)); - } - - setDownloadingTrack(null); // Clear spinner - setCurrentDownloadInfo(null); // Clear download info - setIsDownloading(false); - setBulkDownloadType(null); - shouldStopDownloadRef.current = false; // Reset flag - - if (errorCount === 0) { - toast.success(`Downloaded ${successCount} tracks successfully`); - } else { - toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`); - } - - setSelectedTracks([]); - }; - - const handleDownloadAll = async (tracks: TrackMetadata[], playlistName?: string, isArtistDiscography?: boolean) => { - const tracksWithIsrc = tracks.filter(track => track.isrc); - - if (tracksWithIsrc.length === 0) { - toast.error("No tracks available for download"); - return; - } - - const settings = getSettings(); - setIsDownloading(true); - setBulkDownloadType('all'); - setDownloadProgress(0); - - let successCount = 0; - let errorCount = 0; - const total = tracksWithIsrc.length; - - for (let i = 0; i < tracksWithIsrc.length; i++) { - // Check if user clicked Stop - if (shouldStopDownloadRef.current) { - toast.info(`Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.`); - break; - } - - const track = tracksWithIsrc[i]; - - setDownloadingTrack(track.isrc); // Show spinner on this track - - // Set current download info for progress display - setCurrentDownloadInfo({ name: track.name, artists: track.artists }); - - try { - const response = await downloadWithAutoFallback( - track.isrc, - settings, - track.name, - track.artists, - track.album_name, - playlistName, - isArtistDiscography - ); - - if (response.success) { - successCount++; - setDownloadedTracks(prev => new Set(prev).add(track.isrc)); - } else { - errorCount++; - } - } catch (err) { - errorCount++; - } - - setDownloadProgress(Math.round(((i + 1) / total) * 100)); - } - - setDownloadingTrack(null); // Clear spinner - setCurrentDownloadInfo(null); // Clear download info - setIsDownloading(false); - setBulkDownloadType(null); - shouldStopDownloadRef.current = false; // Reset flag - - if (errorCount === 0) { - toast.success(`Downloaded ${successCount} tracks successfully`); - } else { - toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`); - } + const handleSearchChange = (value: string) => { + setSearchQuery(value); + setCurrentPage(1); }; const toggleTrackSelection = (isrc: string) => { - setSelectedTracks(prev => - prev.includes(isrc) - ? prev.filter(id => id !== isrc) - : [...prev, isrc] + setSelectedTracks((prev) => + prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc] ); }; - const toggleSelectAll = (tracks: TrackMetadata[]) => { - const tracksWithIsrc = tracks.filter(track => track.isrc).map(track => track.isrc); + const toggleSelectAll = (tracks: any[]) => { + const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc); if (selectedTracks.length === tracksWithIsrc.length) { setSelectedTracks([]); } else { @@ -479,22 +116,6 @@ function App() { } }; - const formatDuration = (ms: number) => { - const minutes = Math.floor(ms / 60000); - const seconds = Math.floor((ms % 60000) / 1000); - return `${minutes}:${seconds.toString().padStart(2, '0')}`; - }; - - const handleSearchChange = (value: string) => { - setSearchQuery(value); - setCurrentPage(1); // Reset to first page when searching - }; - - const handleStopDownload = () => { - shouldStopDownloadRef.current = true; - toast.info('Stopping download...'); - }; - const handleOpenFolder = async () => { const settings = getSettings(); if (!settings.downloadPath) { @@ -510,607 +131,125 @@ function App() { } }; - const renderDownloadProgress = () => { - if (!isDownloading) return null; - - return ( -
-
- - -
-

- {downloadProgress}% - {currentDownloadInfo ? `${currentDownloadInfo.name} - ${currentDownloadInfo.artists}` : 'Preparing download...'} -

-
- ); - }; - - const renderTrackList = (tracks: TrackMetadata[], showCheckboxes: boolean = false, hideAlbumColumn: boolean = false) => { - let filteredTracks = tracks.filter(track => { - if (!searchQuery) return true; - const query = searchQuery.toLowerCase(); - return ( - track.name.toLowerCase().includes(query) || - track.artists.toLowerCase().includes(query) || - track.album_name.toLowerCase().includes(query) - ); - }); - - // Apply sorting - if (sortBy === "title-asc") { - filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name)); - } else if (sortBy === "title-desc") { - filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name)); - } else if (sortBy === "artist-asc") { - filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists)); - } else if (sortBy === "artist-desc") { - filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists)); - } else if (sortBy === "duration-asc") { - filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms); - } else if (sortBy === "duration-desc") { - filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms); - } else if (sortBy === "downloaded") { - filteredTracks = [...filteredTracks].sort((a, b) => { - const aDownloaded = downloadedTracks.has(a.isrc); - const bDownloaded = downloadedTracks.has(b.isrc); - return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0); - }); - } else if (sortBy === "not-downloaded") { - filteredTracks = [...filteredTracks].sort((a, b) => { - const aDownloaded = downloadedTracks.has(a.isrc); - const bDownloaded = downloadedTracks.has(b.isrc); - return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0); - }); - } - - // Pagination - const totalPages = Math.ceil(filteredTracks.length / ITEMS_PER_PAGE); - const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; - const endIndex = startIndex + ITEMS_PER_PAGE; - const paginatedTracks = filteredTracks.slice(startIndex, endIndex); - - const tracksWithIsrc = filteredTracks.filter(track => track.isrc); - const allSelected = tracksWithIsrc.length > 0 && tracksWithIsrc.every(track => selectedTracks.includes(track.isrc)); - - return ( -
-
-
- - - - {showCheckboxes && ( - - )} - - - {!hideAlbumColumn && } - - - - - - {paginatedTracks.map((track, index) => ( - - {showCheckboxes && ( - - )} - - - {!hideAlbumColumn && ( - - )} - - - - ))} - -
- toggleSelectAll(filteredTracks)} - /> - #TitleAlbumDurationActions
- {track.isrc && ( - toggleTrackSelection(track.isrc)} - /> - )} - - {startIndex + index + 1} - -
- {track.images && ( - {track.name} - )} -
-
- {track.name} - {downloadedTracks.has(track.isrc) && ( - - )} -
- {track.artists} -
-
-
- {track.album_name} - - {formatDuration(track.duration_ms)} - - {track.isrc && ( - - )} -
-
-
- - {/* Pagination */} - {totalPages > 1 && ( - - - - { - e.preventDefault(); - if (currentPage > 1) setCurrentPage(currentPage - 1); - }} - className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} - /> - - - {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( - - { - e.preventDefault(); - setCurrentPage(page); - }} - isActive={currentPage === page} - className="cursor-pointer" - > - {page} - - - ))} - - - { - e.preventDefault(); - if (currentPage < totalPages) setCurrentPage(currentPage + 1); - }} - className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'} - /> - - - - )} -
- ); - }; - const renderMetadata = () => { - if (!metadata) return null; + if (!metadata.metadata) return null; - if ("track" in metadata) { - const { track } = metadata; + if ("track" in metadata.metadata) { + const { track } = metadata.metadata; return ( - - -
- {track.images && ( - {track.name} - )} -
-
-

{track.name}

-

{track.artists}

-
-
-
-

Album

-

{track.album_name}

-
-
-

Release Date

-

{track.release_date}

-
-
- {track.isrc && ( -
- -
- )} -
-
-
-
+ ); } - if ("album_info" in metadata) { - const { album_info, track_list } = metadata; + if ("album_info" in metadata.metadata) { + const { album_info, track_list } = metadata.metadata; return ( -
- - -
- {album_info.images && ( - {album_info.name} - )} -
-
-

Album

-

{album_info.name}

-
- {album_info.artists} - - {album_info.release_date} - - {album_info.total_tracks} songs -
-
-
- - {selectedTracks.length > 0 && ( - - )} - {downloadedTracks.size > 0 && ( - - )} -
- {renderDownloadProgress()} -
-
-
-
-
-
-
- - handleSearchChange(e.target.value)} - className="pl-10" - /> -
- -
- {renderTrackList(track_list, true, true)} -
-
+ download.handleDownloadAll(track_list, album_info.name)} + onDownloadSelected={() => + download.handleDownloadSelected(selectedTracks, track_list, album_info.name) + } + onStopDownload={download.handleStopDownload} + onOpenFolder={handleOpenFolder} + onPageChange={setCurrentPage} + /> ); } - if ("playlist_info" in metadata) { - const { playlist_info, track_list } = metadata; + if ("playlist_info" in metadata.metadata) { + const { playlist_info, track_list } = metadata.metadata; return ( -
- - -
- {playlist_info.owner.images && ( - {playlist_info.owner.name} - )} -
-
-

Playlist

-

{playlist_info.owner.name}

-
- {playlist_info.owner.display_name} - - {playlist_info.tracks.total} songs - - {playlist_info.followers.total.toLocaleString()} followers -
-
-
- - {selectedTracks.length > 0 && ( - - )} - {downloadedTracks.size > 0 && ( - - )} -
- {renderDownloadProgress()} -
-
-
-
-
-
-
- - handleSearchChange(e.target.value)} - className="pl-10" - /> -
- -
- {renderTrackList(track_list, true)} -
-
+ download.handleDownloadAll(track_list, playlist_info.owner.name)} + onDownloadSelected={() => + download.handleDownloadSelected( + selectedTracks, + track_list, + playlist_info.owner.name + ) + } + onStopDownload={download.handleStopDownload} + onOpenFolder={handleOpenFolder} + onPageChange={setCurrentPage} + /> ); } - if ("artist_info" in metadata) { - const { artist_info, album_list, track_list } = metadata; + if ("artist_info" in metadata.metadata) { + const { artist_info, album_list, track_list } = metadata.metadata; return ( -
- - -
- {artist_info.images && ( - {artist_info.name} - )} -
-

Artist

-

{artist_info.name}

-
- {artist_info.followers.toLocaleString()} followers - {artist_info.genres.length > 0 && ( - <> - - {artist_info.genres.join(", ")} - - )} -
-
-
-
-
- - {album_list.length > 0 && ( -
-

Discography

-
- {album_list.map((album) => ( -
handleAlbumClick({ id: album.id, name: album.name, external_urls: album.external_urls })} - > -
- {album.images && ( - {album.name} - )} -
-

{album.name}

-

- {album.release_date?.split('-')[0]} • {album.album_type} -

-
- ))} -
-
- )} - - {track_list.length > 0 && ( -
-
-

Popular Tracks

-
- - {selectedTracks.length > 0 && ( - - )} - {downloadedTracks.size > 0 && ( - - )} -
-
- {renderDownloadProgress()} -
-
- - handleSearchChange(e.target.value)} - className="pl-10" - /> -
- -
- {renderTrackList(track_list, true)} -
- )} -
- ); - } - - if ("artist" in metadata) { - const { artist } = metadata; - return ( - - - Artist: {artist.name} - - {artist.followers.toLocaleString()} followers • Popularity: {artist.popularity} - - - - {artist.images && ( - {artist.name} - )} - {artist.genres.length > 0 && ( -
- -

{artist.genres.join(", ")}

-
- )} -
-
+ download.handleDownloadAll(track_list, artist_info.name, true)} + onDownloadSelected={() => + download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true) + } + onStopDownload={download.handleStopDownload} + onOpenFolder={handleOpenFolder} + onAlbumClick={metadata.handleAlbumClick} + onPageChange={setCurrentPage} + /> ); } @@ -1121,182 +260,87 @@ function App() {
-
-
-
- SpotiFLAC window.location.reload()} /> -

window.location.reload()}>SpotiFLAC

-
- - - v{CURRENT_VERSION} - - - {hasUpdate && ( - - - - - )} -
-
-

- Get Spotify tracks in true FLAC from Tidal/Deezer — no account required. -

-
-
- - - - - -

Report bug or request feature

-
-
- -
-
+
- {/* Timeout Dialog */} - - - - Fetch Settings - - Set timeout for fetching metadata. Longer timeout is recommended for artists with large discography. - - -
-
- - setTimeoutValue(Number(e.target.value))} - /> -

- Default: 60 seconds. For large discographies, try 300-600 seconds (5-10 minutes). -

-
-
- - - - -
-
- - {/* Album Fetch Dialog */} - - - - Fetch Album - - Do you want to fetch metadata for this album? - - - {selectedAlbum && ( -
-

{selectedAlbum.name}

-
- )} - - - - -
-
- - - -
-
- - - - - - -

Supports track, album, playlist, and artist URLs

-
-
-
-
-
+ {/* Timeout Dialog */} + + + + Fetch Settings + + Set timeout for fetching metadata. Longer timeout is recommended for artists + with large discography. + + +
+
+ setSpotifyUrl(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleFetchMetadata()} - className="pr-8" + id="timeout" + type="number" + min="10" + max="600" + value={metadata.timeoutValue} + onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))} /> - {spotifyUrl && ( - - )} +

+ Default: 60 seconds. For large discographies, try 300-600 seconds (5-10 + minutes). +

-
-
- - + + + + + + - {metadata && renderMetadata()} + {/* Album Fetch Dialog */} + + + + Fetch Album + + Do you want to fetch metadata for this album? + + + {metadata.selectedAlbum && ( +
+

{metadata.selectedAlbum.name}

+
+ )} + + + + +
+
+ + + + {metadata.metadata && renderMetadata()}
diff --git a/frontend/src/components/AlbumInfo.tsx b/frontend/src/components/AlbumInfo.tsx new file mode 100644 index 0000000..70056ac --- /dev/null +++ b/frontend/src/components/AlbumInfo.tsx @@ -0,0 +1,164 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Download, FolderOpen } from "lucide-react"; +import { Spinner } from "@/components/ui/spinner"; +import { SearchAndSort } from "./SearchAndSort"; +import { TrackList } from "./TrackList"; +import { DownloadProgress } from "./DownloadProgress"; +import type { TrackMetadata } from "@/types/api"; + +interface AlbumInfoProps { + albumInfo: { + name: string; + artists: string; + images: string; + release_date: string; + total_tracks: number; + }; + trackList: TrackMetadata[]; + searchQuery: string; + sortBy: string; + selectedTracks: string[]; + downloadedTracks: Set; + downloadingTrack: string | null; + isDownloading: boolean; + bulkDownloadType: "all" | "selected" | null; + downloadProgress: number; + currentDownloadInfo: { name: string; artists: string } | null; + currentPage: number; + itemsPerPage: number; + onSearchChange: (value: string) => void; + onSortChange: (value: string) => void; + onToggleTrack: (isrc: string) => void; + onToggleSelectAll: (tracks: TrackMetadata[]) => void; + onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void; + onDownloadAll: () => void; + onDownloadSelected: () => void; + onStopDownload: () => void; + onOpenFolder: () => void; + onPageChange: (page: number) => void; +} + +export function AlbumInfo({ + albumInfo, + trackList, + searchQuery, + sortBy, + selectedTracks, + downloadedTracks, + downloadingTrack, + isDownloading, + bulkDownloadType, + downloadProgress, + currentDownloadInfo, + currentPage, + itemsPerPage, + onSearchChange, + onSortChange, + onToggleTrack, + onToggleSelectAll, + onDownloadTrack, + onDownloadAll, + onDownloadSelected, + onStopDownload, + onOpenFolder, + onPageChange, +}: AlbumInfoProps) { + return ( +
+ + +
+ {albumInfo.images && ( + {albumInfo.name} + )} +
+
+

Album

+

{albumInfo.name}

+
+ {albumInfo.artists} + + {albumInfo.release_date} + + {albumInfo.total_tracks} songs +
+
+
+ + {selectedTracks.length > 0 && ( + + )} + {downloadedTracks.size > 0 && ( + + )} +
+ {isDownloading && ( + + )} +
+
+
+
+
+ + +
+
+ ); +} diff --git a/frontend/src/components/ArtistInfo.tsx b/frontend/src/components/ArtistInfo.tsx new file mode 100644 index 0000000..82153cb --- /dev/null +++ b/frontend/src/components/ArtistInfo.tsx @@ -0,0 +1,217 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Download, FolderOpen } from "lucide-react"; +import { Spinner } from "@/components/ui/spinner"; +import { SearchAndSort } from "./SearchAndSort"; +import { TrackList } from "./TrackList"; +import { DownloadProgress } from "./DownloadProgress"; +import type { TrackMetadata } from "@/types/api"; + +interface ArtistInfoProps { + artistInfo: { + name: string; + images: string; + followers: number; + genres: string[]; + }; + albumList: Array<{ + id: string; + name: string; + images: string; + release_date: string; + album_type: string; + external_urls: string; + }>; + trackList: TrackMetadata[]; + searchQuery: string; + sortBy: string; + selectedTracks: string[]; + downloadedTracks: Set; + downloadingTrack: string | null; + isDownloading: boolean; + bulkDownloadType: "all" | "selected" | null; + downloadProgress: number; + currentDownloadInfo: { name: string; artists: string } | null; + currentPage: number; + itemsPerPage: number; + onSearchChange: (value: string) => void; + onSortChange: (value: string) => void; + onToggleTrack: (isrc: string) => void; + onToggleSelectAll: (tracks: TrackMetadata[]) => void; + onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void; + onDownloadAll: () => void; + onDownloadSelected: () => void; + onStopDownload: () => void; + onOpenFolder: () => void; + onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void; + onPageChange: (page: number) => void; +} + +export function ArtistInfo({ + artistInfo, + albumList, + trackList, + searchQuery, + sortBy, + selectedTracks, + downloadedTracks, + downloadingTrack, + isDownloading, + bulkDownloadType, + downloadProgress, + currentDownloadInfo, + currentPage, + itemsPerPage, + onSearchChange, + onSortChange, + onToggleTrack, + onToggleSelectAll, + onDownloadTrack, + onDownloadAll, + onDownloadSelected, + onStopDownload, + onOpenFolder, + onAlbumClick, + onPageChange, +}: ArtistInfoProps) { + return ( +
+ + +
+ {artistInfo.images && ( + {artistInfo.name} + )} +
+

Artist

+

{artistInfo.name}

+
+ {artistInfo.followers.toLocaleString()} followers + {artistInfo.genres.length > 0 && ( + <> + + {artistInfo.genres.join(", ")} + + )} +
+
+
+
+
+ + {albumList.length > 0 && ( +
+

Discography

+
+ {albumList.map((album) => ( +
+ onAlbumClick({ + id: album.id, + name: album.name, + external_urls: album.external_urls, + }) + } + > +
+ {album.images && ( + {album.name} + )} +
+

{album.name}

+

+ {album.release_date?.split("-")[0]} • {album.album_type} +

+
+ ))} +
+
+ )} + + {trackList.length > 0 && ( +
+
+

Popular Tracks

+
+ + {selectedTracks.length > 0 && ( + + )} + {downloadedTracks.size > 0 && ( + + )} +
+
+ {isDownloading && ( + + )} + + +
+ )} +
+ ); +} diff --git a/frontend/src/components/DownloadProgress.tsx b/frontend/src/components/DownloadProgress.tsx new file mode 100644 index 0000000..35bd701 --- /dev/null +++ b/frontend/src/components/DownloadProgress.tsx @@ -0,0 +1,29 @@ +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { StopCircle } from "lucide-react"; + +interface DownloadProgressProps { + progress: number; + currentTrack: { name: string; artists: string } | null; + onStop: () => void; +} + +export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) { + return ( +
+
+ + +
+

+ {progress}% -{" "} + {currentTrack + ? `${currentTrack.name} - ${currentTrack.artists}` + : "Preparing download..."} +

+
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..15f1f03 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -0,0 +1,79 @@ +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Settings } from "@/components/Settings"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface HeaderProps { + version: string; + hasUpdate: boolean; +} + +export function Header({ version, hasUpdate }: HeaderProps) { + return ( +
+
+
+ SpotiFLAC window.location.reload()} + /> +

window.location.reload()} + > + SpotiFLAC +

+
+ + + v{version} + + + {hasUpdate && ( + + + + + )} +
+
+

+ Get Spotify tracks in true FLAC from Tidal/Deezer — no account required. +

+
+
+ + + + + +

Report bug or request feature

+
+
+ +
+
+ ); +} diff --git a/frontend/src/components/PlaylistInfo.tsx b/frontend/src/components/PlaylistInfo.tsx new file mode 100644 index 0000000..5d8a523 --- /dev/null +++ b/frontend/src/components/PlaylistInfo.tsx @@ -0,0 +1,170 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Download, FolderOpen } from "lucide-react"; +import { Spinner } from "@/components/ui/spinner"; +import { SearchAndSort } from "./SearchAndSort"; +import { TrackList } from "./TrackList"; +import { DownloadProgress } from "./DownloadProgress"; +import type { TrackMetadata } from "@/types/api"; + +interface PlaylistInfoProps { + playlistInfo: { + owner: { + name: string; + display_name: string; + images: string; + }; + tracks: { + total: number; + }; + followers: { + total: number; + }; + }; + trackList: TrackMetadata[]; + searchQuery: string; + sortBy: string; + selectedTracks: string[]; + downloadedTracks: Set; + downloadingTrack: string | null; + isDownloading: boolean; + bulkDownloadType: "all" | "selected" | null; + downloadProgress: number; + currentDownloadInfo: { name: string; artists: string } | null; + currentPage: number; + itemsPerPage: number; + onSearchChange: (value: string) => void; + onSortChange: (value: string) => void; + onToggleTrack: (isrc: string) => void; + onToggleSelectAll: (tracks: TrackMetadata[]) => void; + onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void; + onDownloadAll: () => void; + onDownloadSelected: () => void; + onStopDownload: () => void; + onOpenFolder: () => void; + onPageChange: (page: number) => void; +} + +export function PlaylistInfo({ + playlistInfo, + trackList, + searchQuery, + sortBy, + selectedTracks, + downloadedTracks, + downloadingTrack, + isDownloading, + bulkDownloadType, + downloadProgress, + currentDownloadInfo, + currentPage, + itemsPerPage, + onSearchChange, + onSortChange, + onToggleTrack, + onToggleSelectAll, + onDownloadTrack, + onDownloadAll, + onDownloadSelected, + onStopDownload, + onOpenFolder, + onPageChange, +}: PlaylistInfoProps) { + return ( +
+ + +
+ {playlistInfo.owner.images && ( + {playlistInfo.owner.name} + )} +
+
+

Playlist

+

{playlistInfo.owner.name}

+
+ {playlistInfo.owner.display_name} + + {playlistInfo.tracks.total} songs + + {playlistInfo.followers.total.toLocaleString()} followers +
+
+
+ + {selectedTracks.length > 0 && ( + + )} + {downloadedTracks.size > 0 && ( + + )} +
+ {isDownloading && ( + + )} +
+
+
+
+
+ + +
+
+ ); +} diff --git a/frontend/src/components/SearchAndSort.tsx b/frontend/src/components/SearchAndSort.tsx new file mode 100644 index 0000000..3588fbc --- /dev/null +++ b/frontend/src/components/SearchAndSort.tsx @@ -0,0 +1,54 @@ +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Search, ArrowUpDown } from "lucide-react"; + +interface SearchAndSortProps { + searchQuery: string; + sortBy: string; + onSearchChange: (value: string) => void; + onSortChange: (value: string) => void; +} + +export function SearchAndSort({ + searchQuery, + sortBy, + onSearchChange, + onSortChange, +}: SearchAndSortProps) { + return ( +
+
+ + onSearchChange(e.target.value)} + className="pl-10" + /> +
+ +
+ ); +} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx new file mode 100644 index 0000000..81e2fbf --- /dev/null +++ b/frontend/src/components/SearchBar.tsx @@ -0,0 +1,74 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Search, Info, XCircle } from "lucide-react"; +import { Spinner } from "@/components/ui/spinner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface SearchBarProps { + url: string; + loading: boolean; + onUrlChange: (url: string) => void; + onFetch: () => void; +} + +export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) { + return ( + + +
+
+ + + + + + +

Supports track, album, playlist, and artist URLs

+
+
+
+
+
+ onUrlChange(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && onFetch()} + className="pr-8" + /> + {url && ( + + )} +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/TrackInfo.tsx b/frontend/src/components/TrackInfo.tsx new file mode 100644 index 0000000..8c624be --- /dev/null +++ b/frontend/src/components/TrackInfo.tsx @@ -0,0 +1,78 @@ +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Download, FolderOpen } from "lucide-react"; +import { Spinner } from "@/components/ui/spinner"; +import type { TrackMetadata } from "@/types/api"; + +interface TrackInfoProps { + track: TrackMetadata & { album_name: string; release_date: string }; + isDownloading: boolean; + downloadingTrack: string | null; + isDownloaded: boolean; + onDownload: (isrc: string, name: string, artists: string) => void; + onOpenFolder: () => void; +} + +export function TrackInfo({ + track, + isDownloading, + downloadingTrack, + isDownloaded, + onDownload, + onOpenFolder, +}: TrackInfoProps) { + return ( + + +
+ {track.images && ( + {track.name} + )} +
+
+

{track.name}

+

{track.artists}

+
+
+
+

Album

+

{track.album_name}

+
+
+

Release Date

+

{track.release_date}

+
+
+ {track.isrc && ( +
+ + {isDownloaded && ( + + )} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/TrackList.tsx b/frontend/src/components/TrackList.tsx new file mode 100644 index 0000000..b445a1c --- /dev/null +++ b/frontend/src/components/TrackList.tsx @@ -0,0 +1,261 @@ +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Download, CheckCircle } from "lucide-react"; +import { Spinner } from "@/components/ui/spinner"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import type { TrackMetadata } from "@/types/api"; + +interface TrackListProps { + tracks: TrackMetadata[]; + searchQuery: string; + sortBy: string; + selectedTracks: string[]; + downloadedTracks: Set; + downloadingTrack: string | null; + isDownloading: boolean; + currentPage: number; + itemsPerPage: number; + showCheckboxes?: boolean; + hideAlbumColumn?: boolean; + onToggleTrack: (isrc: string) => void; + onToggleSelectAll: (tracks: TrackMetadata[]) => void; + onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void; + onPageChange: (page: number) => void; +} + +export function TrackList({ + tracks, + searchQuery, + sortBy, + selectedTracks, + downloadedTracks, + downloadingTrack, + isDownloading, + currentPage, + itemsPerPage, + showCheckboxes = false, + hideAlbumColumn = false, + onToggleTrack, + onToggleSelectAll, + onDownloadTrack, + onPageChange, +}: TrackListProps) { + let filteredTracks = tracks.filter((track) => { + if (!searchQuery) return true; + const query = searchQuery.toLowerCase(); + return ( + track.name.toLowerCase().includes(query) || + track.artists.toLowerCase().includes(query) || + track.album_name.toLowerCase().includes(query) + ); + }); + + // Apply sorting + if (sortBy === "title-asc") { + filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name)); + } else if (sortBy === "title-desc") { + filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name)); + } else if (sortBy === "artist-asc") { + filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists)); + } else if (sortBy === "artist-desc") { + filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists)); + } else if (sortBy === "duration-asc") { + filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms); + } else if (sortBy === "duration-desc") { + filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms); + } else if (sortBy === "downloaded") { + filteredTracks = [...filteredTracks].sort((a, b) => { + const aDownloaded = downloadedTracks.has(a.isrc); + const bDownloaded = downloadedTracks.has(b.isrc); + return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0); + }); + } else if (sortBy === "not-downloaded") { + filteredTracks = [...filteredTracks].sort((a, b) => { + const aDownloaded = downloadedTracks.has(a.isrc); + const bDownloaded = downloadedTracks.has(b.isrc); + return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0); + }); + } + + const totalPages = Math.ceil(filteredTracks.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const paginatedTracks = filteredTracks.slice(startIndex, endIndex); + + const tracksWithIsrc = filteredTracks.filter((track) => track.isrc); + const allSelected = + tracksWithIsrc.length > 0 && + tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc)); + + 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 ( +
+
+
+ + + + {showCheckboxes && ( + + )} + + + {!hideAlbumColumn && ( + + )} + + + + + + {paginatedTracks.map((track, index) => ( + + {showCheckboxes && ( + + )} + + + {!hideAlbumColumn && ( + + )} + + + + ))} + +
+ onToggleSelectAll(filteredTracks)} + /> + + # + + Title + + Album + + Duration + + Actions +
+ {track.isrc && ( + onToggleTrack(track.isrc)} + /> + )} + + {startIndex + index + 1} + +
+ {track.images && ( + {track.name} + )} +
+
+ {track.name} + {downloadedTracks.has(track.isrc) && ( + + )} +
+ + {track.artists} + +
+
+
+ {track.album_name} + + {formatDuration(track.duration_ms)} + + {track.isrc && ( + + )} +
+
+
+ + {totalPages > 1 && ( + + + + { + e.preventDefault(); + if (currentPage > 1) onPageChange(currentPage - 1); + }} + className={ + currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer" + } + /> + + + {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( + + { + e.preventDefault(); + onPageChange(page); + }} + isActive={currentPage === page} + className="cursor-pointer" + > + {page} + + + ))} + + + { + e.preventDefault(); + if (currentPage < totalPages) onPageChange(currentPage + 1); + }} + className={ + currentPage === totalPages + ? "pointer-events-none opacity-50" + : "cursor-pointer" + } + /> + + + + )} +
+ ); +} diff --git a/frontend/src/hooks/useDownload.ts b/frontend/src/hooks/useDownload.ts new file mode 100644 index 0000000..1f27b50 --- /dev/null +++ b/frontend/src/hooks/useDownload.ts @@ -0,0 +1,290 @@ +import { useState, useRef } from "react"; +import { downloadTrack } from "@/lib/api"; +import { getSettings } from "@/lib/settings"; +import { toastWithSound as toast } from "@/lib/toast-with-sound"; +import { joinPath, sanitizePath } from "@/lib/utils"; +import type { TrackMetadata } from "@/types/api"; + +export function useDownload() { + const [downloadProgress, setDownloadProgress] = useState(0); + const [isDownloading, setIsDownloading] = useState(false); + const [downloadingTrack, setDownloadingTrack] = useState(null); + const [bulkDownloadType, setBulkDownloadType] = useState<"all" | "selected" | null>(null); + const [downloadedTracks, setDownloadedTracks] = useState>(new Set()); + const [currentDownloadInfo, setCurrentDownloadInfo] = useState<{ + name: string; + artists: string; + } | null>(null); + const shouldStopDownloadRef = useRef(false); + + const downloadWithAutoFallback = async ( + isrc: string, + settings: any, + trackName?: string, + artistName?: string, + albumName?: string, + playlistName?: string, + isArtistDiscography?: boolean + ) => { + let service = settings.downloader; + + const query = trackName && artistName ? `${trackName} ${artistName}` : undefined; + const os = settings.operatingSystem; + + let outputDir = settings.downloadPath; + + if (playlistName) { + outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os)); + + if (isArtistDiscography) { + if (settings.albumSubfolder && albumName) { + outputDir = joinPath(os, outputDir, sanitizePath(albumName, os)); + } + } else { + if (settings.artistSubfolder && artistName) { + outputDir = joinPath(os, outputDir, sanitizePath(artistName, os)); + } + + if (settings.albumSubfolder && albumName) { + outputDir = joinPath(os, outputDir, sanitizePath(albumName, os)); + } + } + } + + if (service === "auto") { + try { + const tidalResponse = await downloadTrack({ + isrc, + service: "tidal", + query, + output_dir: outputDir, + filename_format: settings.filenameFormat, + track_number: settings.trackNumber, + }); + + if (tidalResponse.success) { + return tidalResponse; + } + + service = "deezer"; + } catch (tidalErr) { + service = "deezer"; + } + } + + return await downloadTrack({ + isrc, + service: service as "deezer" | "tidal", + query, + output_dir: outputDir, + filename_format: settings.filenameFormat, + track_number: settings.trackNumber, + }); + }; + + const handleDownloadTrack = async ( + isrc: string, + trackName?: string, + artistName?: string, + albumName?: string + ) => { + if (!isrc) { + toast.error("No ISRC found for this track"); + return; + } + + const settings = getSettings(); + setDownloadingTrack(isrc); + + try { + const response = await downloadWithAutoFallback( + isrc, + settings, + trackName, + artistName, + albumName, + undefined, + false + ); + + if (response.success) { + toast.success(response.message); + setDownloadedTracks((prev) => new Set(prev).add(isrc)); + } else { + toast.error(response.error || "Download failed"); + } + } catch (err) { + toast.error(err instanceof Error ? err.message : "Download failed"); + } finally { + setDownloadingTrack(null); + } + }; + + const handleDownloadSelected = async ( + selectedTracks: string[], + allTracks: TrackMetadata[], + playlistName?: string, + isArtistDiscography?: boolean + ) => { + if (selectedTracks.length === 0) { + toast.error("No tracks selected"); + return; + } + + const settings = getSettings(); + setIsDownloading(true); + setBulkDownloadType("selected"); + setDownloadProgress(0); + + let successCount = 0; + let errorCount = 0; + const total = selectedTracks.length; + + for (let i = 0; i < selectedTracks.length; i++) { + if (shouldStopDownloadRef.current) { + toast.info( + `Download stopped. ${successCount} tracks downloaded, ${selectedTracks.length - i} skipped.` + ); + break; + } + + const isrc = selectedTracks[i]; + const track = allTracks.find((t) => t.isrc === isrc); + + setDownloadingTrack(isrc); + + if (track) { + setCurrentDownloadInfo({ name: track.name, artists: track.artists }); + } + + try { + const response = await downloadWithAutoFallback( + isrc, + settings, + track?.name, + track?.artists, + track?.album_name, + playlistName, + isArtistDiscography + ); + + if (response.success) { + successCount++; + setDownloadedTracks((prev) => new Set(prev).add(isrc)); + } else { + errorCount++; + } + } catch (err) { + errorCount++; + } + + setDownloadProgress(Math.round(((i + 1) / total) * 100)); + } + + setDownloadingTrack(null); + setCurrentDownloadInfo(null); + setIsDownloading(false); + setBulkDownloadType(null); + shouldStopDownloadRef.current = false; + + if (errorCount === 0) { + toast.success(`Downloaded ${successCount} tracks successfully`); + } else { + toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`); + } + }; + + const handleDownloadAll = async ( + tracks: TrackMetadata[], + playlistName?: string, + isArtistDiscography?: boolean + ) => { + const tracksWithIsrc = tracks.filter((track) => track.isrc); + + if (tracksWithIsrc.length === 0) { + toast.error("No tracks available for download"); + return; + } + + const settings = getSettings(); + setIsDownloading(true); + setBulkDownloadType("all"); + setDownloadProgress(0); + + let successCount = 0; + let errorCount = 0; + const total = tracksWithIsrc.length; + + for (let i = 0; i < tracksWithIsrc.length; i++) { + if (shouldStopDownloadRef.current) { + toast.info( + `Download stopped. ${successCount} tracks downloaded, ${tracksWithIsrc.length - i} skipped.` + ); + break; + } + + const track = tracksWithIsrc[i]; + + setDownloadingTrack(track.isrc); + setCurrentDownloadInfo({ name: track.name, artists: track.artists }); + + try { + const response = await downloadWithAutoFallback( + track.isrc, + settings, + track.name, + track.artists, + track.album_name, + playlistName, + isArtistDiscography + ); + + if (response.success) { + successCount++; + setDownloadedTracks((prev) => new Set(prev).add(track.isrc)); + } else { + errorCount++; + } + } catch (err) { + errorCount++; + } + + setDownloadProgress(Math.round(((i + 1) / total) * 100)); + } + + setDownloadingTrack(null); + setCurrentDownloadInfo(null); + setIsDownloading(false); + setBulkDownloadType(null); + shouldStopDownloadRef.current = false; + + if (errorCount === 0) { + toast.success(`Downloaded ${successCount} tracks successfully`); + } else { + toast.warning(`Downloaded ${successCount} tracks, ${errorCount} failed`); + } + }; + + const handleStopDownload = () => { + shouldStopDownloadRef.current = true; + toast.info("Stopping download..."); + }; + + const resetDownloadedTracks = () => { + setDownloadedTracks(new Set()); + }; + + return { + downloadProgress, + isDownloading, + downloadingTrack, + bulkDownloadType, + downloadedTracks, + currentDownloadInfo, + handleDownloadTrack, + handleDownloadSelected, + handleDownloadAll, + handleStopDownload, + resetDownloadedTracks, + }; +} diff --git a/frontend/src/hooks/useMetadata.ts b/frontend/src/hooks/useMetadata.ts new file mode 100644 index 0000000..91c4b0f --- /dev/null +++ b/frontend/src/hooks/useMetadata.ts @@ -0,0 +1,116 @@ +import { useState } from "react"; +import { fetchSpotifyMetadata } from "@/lib/api"; +import { toastWithSound as toast } from "@/lib/toast-with-sound"; +import type { SpotifyMetadataResponse } from "@/types/api"; + +export function useMetadata() { + const [loading, setLoading] = useState(false); + const [metadata, setMetadata] = useState(null); + const [showTimeoutDialog, setShowTimeoutDialog] = useState(false); + const [timeoutValue, setTimeoutValue] = useState(60); + const [pendingUrl, setPendingUrl] = useState(""); + const [showAlbumDialog, setShowAlbumDialog] = useState(false); + const [selectedAlbum, setSelectedAlbum] = useState<{ + id: string; + name: string; + external_urls: string; + } | null>(null); + + const fetchMetadataDirectly = async (url: string) => { + setLoading(true); + setMetadata(null); + + try { + const data = await fetchSpotifyMetadata(url); + setMetadata(data); + toast.success("Metadata fetched successfully"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to fetch metadata"); + } finally { + setLoading(false); + } + }; + + const handleFetchMetadata = async (url: string) => { + if (!url.trim()) { + toast.error("Please enter a Spotify URL"); + return; + } + + let urlToFetch = url.trim(); + const isArtistUrl = urlToFetch.includes("/artist/"); + + if (isArtistUrl && !urlToFetch.includes("/discography")) { + urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all"; + } + + if (isArtistUrl) { + setPendingUrl(urlToFetch); + setShowTimeoutDialog(true); + } else { + await fetchMetadataDirectly(urlToFetch); + } + + return urlToFetch; + }; + + const handleConfirmFetch = async () => { + setShowTimeoutDialog(false); + setLoading(true); + setMetadata(null); + + try { + const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue); + setMetadata(data); + toast.success("Metadata fetched successfully"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to fetch metadata"); + } finally { + setLoading(false); + } + }; + + const handleAlbumClick = (album: { + id: string; + name: string; + external_urls: string; + }) => { + setSelectedAlbum(album); + setShowAlbumDialog(true); + }; + + const handleConfirmAlbumFetch = async () => { + if (!selectedAlbum) return; + + setShowAlbumDialog(false); + setLoading(true); + setMetadata(null); + + try { + const data = await fetchSpotifyMetadata(selectedAlbum.external_urls); + setMetadata(data); + toast.success("Album metadata fetched successfully"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to fetch album metadata"); + } finally { + setLoading(false); + setSelectedAlbum(null); + } + }; + + return { + loading, + metadata, + showTimeoutDialog, + setShowTimeoutDialog, + timeoutValue, + setTimeoutValue, + showAlbumDialog, + setShowAlbumDialog, + selectedAlbum, + handleFetchMetadata, + handleConfirmFetch, + handleAlbumClick, + handleConfirmAlbumFetch, + }; +} diff --git a/wails.json b/wails.json index 5883761..4b2ad3e 100644 --- a/wails.json +++ b/wails.json @@ -13,7 +13,7 @@ "info": { "companyName": "afkarxyz", "productName": "SpotiFLAC", - "productVersion": "5.6", + "productVersion": "5.7", "copyright": "Copyright © 2025", "comments": "Get Spotify tracks in true FLAC from Tidal/Deezer — no account required." },