"use client"; import { useState, useEffect, useRef, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Image from "next/image"; import { ArrowLeft, Check, X, Download, Loader2, ExternalLink, ChevronDown, ChevronUp, } from "lucide-react"; import { api } from "@/lib/api"; import { useToast } from "@/lib/toast-context"; // Types for Spotify Import interface SpotifyTrack { spotifyId: string; title: string; artist: string; artistId: string; album: string; albumId: string; isrc: string | null; durationMs: number; trackNumber: number; previewUrl: string | null; coverUrl: string | null; } interface MatchedTrack { spotifyTrack: SpotifyTrack; localTrack: { id: string; title: string; albumId: string; albumTitle: string; artistName: string; } | null; matchType: "exact" | "fuzzy" | "none"; matchConfidence: number; } interface AlbumToDownload { spotifyAlbumId: string; albumName: string; artistName: string; artistMbid: string | null; albumMbid: string | null; coverUrl: string | null; trackCount: number; tracksNeeded: SpotifyTrack[]; } interface ImportPreview { playlist: { id: string; name: string; description: string | null; owner: string; imageUrl: string | null; trackCount: number; }; matchedTracks: MatchedTrack[]; albumsToDownload: AlbumToDownload[]; summary: { total: number; inLibrary: number; downloadable: number; notFound: number; }; } interface ImportJob { id: string; status: | "pending" | "downloading" | "scanning" | "creating_playlist" | "matching_tracks" | "completed" | "failed" | "cancelled"; progress: number; albumsTotal: number; albumsCompleted: number; tracksMatched: number; tracksTotal: number; tracksDownloadable: number; createdPlaylistId: string | null; error: string | null; } type Step = "input" | "preview" | "importing" | "complete"; function SpotifyImportPageContent() { const router = useRouter(); const searchParams = useSearchParams(); const { toast } = useToast(); const hasAutoFetched = useRef(false); // State const [step, setStep] = useState("input"); const [url, setUrl] = useState(""); const [isLoading, setIsLoading] = useState(false); const [preview, setPreview] = useState(null); const [selectedAlbums, setSelectedAlbums] = useState>( new Set() ); const [playlistName, setPlaylistName] = useState(""); const [importJob, setImportJob] = useState(null); const [refreshStatusMessage, setRefreshStatusMessage] = useState< string | null >(null); const [expandedSection, setExpandedSection] = useState< "matched" | "download" | "notfound" | null >("matched"); // Auto-fetch preview if URL is provided in query params useEffect(() => { const urlParam = searchParams.get("url"); if (urlParam && !hasAutoFetched.current) { hasAutoFetched.current = true; setUrl(urlParam); // Auto-trigger preview fetch (async () => { setIsLoading(true); try { const result = await api.post( "/spotify/preview", { url: urlParam, } ); setPreview(result); setPlaylistName(result.playlist.name); // Auto-select all albums (Soulseek can search for any track, even without MBID) const downloadableAlbumIds = result.albumsToDownload.map( (a) => a.albumMbid || a.spotifyAlbumId ); setSelectedAlbums(new Set(downloadableAlbumIds)); setStep("preview"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to fetch playlist"; toast.error(message); } finally { setIsLoading(false); } })(); } }, [searchParams, toast]); // Poll for import job status useEffect(() => { if ( !importJob || importJob.status === "completed" || importJob.status === "failed" || importJob.status === "cancelled" ) { return; } const interval = setInterval(async () => { try { const job = await api.get( `/spotify/import/${importJob.id}/status` ); setImportJob(job); if (job.status === "completed") { setStep("complete"); window.dispatchEvent( new CustomEvent("notifications-changed") ); window.dispatchEvent(new CustomEvent("playlist-created")); } else if (job.status === "cancelled") { setStep("complete"); window.dispatchEvent( new CustomEvent("notifications-changed") ); window.dispatchEvent(new CustomEvent("playlist-created")); } else if (job.status === "failed") { setStep("complete"); window.dispatchEvent( new CustomEvent("notifications-changed") ); } } catch (err) { console.error("Failed to poll job status:", err); } }, 2000); return () => clearInterval(interval); }, [importJob, toast]); // Handle URL paste/change const handleUrlChange = (e: React.ChangeEvent) => { setUrl(e.target.value); }; // Fetch preview const handleFetchPreview = async () => { if (!url.trim()) { toast.error("Please enter a playlist URL"); return; } setIsLoading(true); try { const result = await api.post("/spotify/preview", { url, }); setPreview(result); setPlaylistName(result.playlist.name); // Auto-select all albums (Soulseek can search for any track, even without MBID) const downloadableAlbumIds = result.albumsToDownload.map( (a) => a.albumMbid || a.spotifyAlbumId ); setSelectedAlbums(new Set(downloadableAlbumIds)); setStep("preview"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to fetch playlist"; toast.error(message); } finally { setIsLoading(false); } }; // Start import const handleStartImport = async () => { if (!preview) return; setIsLoading(true); setRefreshStatusMessage(null); try { const response = await api.post<{ jobId: string; status: string }>( "/spotify/import", { spotifyPlaylistId: preview.playlist.id, url, playlistName: playlistName || preview.playlist.name, albumMbidsToDownload: Array.from(selectedAlbums), } ); setImportJob({ id: response.jobId, status: "pending", progress: 0, albumsTotal: selectedAlbums.size, albumsCompleted: 0, tracksMatched: preview.summary.inLibrary, tracksTotal: preview.summary.total, tracksDownloadable: preview.summary.downloadable, createdPlaylistId: null, error: null, }); setStep("importing"); } catch (err) { const message = err instanceof Error ? err.message : "Failed to start import"; toast.error(message); } finally { setIsLoading(false); } }; // Toggle album selection const toggleAlbum = (albumMbid: string) => { setSelectedAlbums((prev) => { const next = new Set(prev); if (next.has(albumMbid)) { next.delete(albumMbid); } else { next.add(albumMbid); } return next; }); }; // Select/deselect all albums const toggleAllAlbums = () => { if (!preview) return; // All albums are downloadable via Soulseek (even without MBID) const allAlbumIds = preview.albumsToDownload.map( (a) => a.albumMbid || a.spotifyAlbumId ); if (selectedAlbums.size === allAlbumIds.length) { setSelectedAlbums(new Set()); } else { setSelectedAlbums(new Set(allAlbumIds)); } }; // Cancel import const [isCancelling, setIsCancelling] = useState(false); const handleCancelImport = async () => { if (!importJob) return; setIsCancelling(true); try { const result = await api.post<{ message: string; playlistId: string | null; tracksMatched: number; }>(`/spotify/import/${importJob.id}/cancel`, {}); setImportJob((prev) => prev ? { ...prev, status: "cancelled", createdPlaylistId: null, tracksMatched: 0, } : prev ); setStep("complete"); // Only dispatch notifications-changed, not playlist-created since no playlist was made window.dispatchEvent(new CustomEvent("notifications-changed")); } catch (err) { const message = err instanceof Error ? err.message : "Failed to cancel import"; toast.error(message); } finally { setIsCancelling(false); } }; // Format duration 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 (
{/* Quick gradient fade - yellow to purple like home page */}
{/* Header */}

Import Playlist

Import from Spotify or Deezer and download missing albums

{/* Browse Link */}

Looking for playlists to import?{" "} Browse Deezer playlists & radio stations →

{/* Step: Input */} {step === "input" && (
e.key === "Enter" && handleFetchPreview() } />

Paste a public{" "} Deezer{" "} or{" "} Spotify{" "} playlist URL

)} {/* Step: Preview */} {step === "preview" && preview && (
{/* Playlist Info */}
{preview.playlist.imageUrl ? ( {preview.playlist.name} ) : (
Spotify
)}

{preview.playlist.name}

{preview.playlist.owner} ·{" "} {preview.playlist.trackCount} songs

{preview.playlist.description && (

{preview.playlist.description}

)}
{/* Summary Stats */}
{preview.summary.total}
Total
{preview.summary.inLibrary}
In Library
{ preview.albumsToDownload.filter( (a) => a.albumMbid ).length }
To Download
{preview.summary.notFound > 0 ? (
{preview.summary.notFound}
Not Found
) : (
All Matched
)}
{/* Tracks already in library */} {preview.summary.inLibrary > 0 && (
{expandedSection === "matched" && (
{preview.matchedTracks .filter((m) => m.localTrack) .map((match, i) => (
{i + 1}
{match.localTrack ?.title || match .spotifyTrack .title}
{match.localTrack ?.artistName || match .spotifyTrack .artist}
{formatDuration( match.spotifyTrack .durationMs )}
))}
)}
)} {/* Albums to download */} {preview.albumsToDownload.filter((a) => a.albumMbid) .length > 0 && (
{expandedSection === "download" && (
{selectedAlbums.size} selected
{preview.albumsToDownload.map( (album, index) => { const albumKey = album.albumMbid || album.spotifyAlbumId; return ( ); } )}
)}
)} {/* Tracks not found */} {preview.summary.notFound > 0 && (
{expandedSection === "notfound" && (
{preview.albumsToDownload .filter( (a) => !a.albumMbid && a.albumName !== "Unknown Album" ) .flatMap( (album) => album.tracksNeeded ) .map((track) => (
{track.title}
{track.artist} ·{" "} {track.album}
))}
)}
)} {/* Playlist name input */}
setPlaylistName(e.target.value) } placeholder="Enter playlist name" className="w-full bg-white/5 border border-white/10 rounded-lg px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-[#1DB954]/50 focus:border-[#1DB954] transition-colors" />
{/* Action buttons */}
)} {/* Step: Importing */} {step === "importing" && importJob && (

{importJob.status === "downloading" ? "Queueing Album Downloads" : importJob.status === "scanning" ? "Scanning Library" : importJob.status === "creating_playlist" || importJob.status === "matching_tracks" ? "Creating Playlist" : importJob.status === "pending" ? "Waiting for Downloads" : "Starting Import"}

{importJob.status === "downloading" && ( <> Queued {importJob.albumsCompleted} of{" "} {importJob.albumsTotal} albums )} {importJob.status === "pending" && ( <> Waiting for{" "} {importJob.albumsTotal - importJob.albumsCompleted}{" "} downloads to complete )} {importJob.status === "scanning" && ( <>Importing downloaded files into library )} {(importJob.status === "creating_playlist" || importJob.status === "matching_tracks") && ( <>Adding {importJob.tracksMatched} songs )}

{importJob.progress}% complete • downloads continue in the background

{/* Cancel button */}

Playlist will be created with tracks downloaded so far

)} {/* Step: Complete */} {step === "complete" && importJob && (
{importJob.status === "failed" || importJob.status === "cancelled" ? ( ) : ( )}

{importJob.status === "failed" ? "Import Failed" : importJob.status === "cancelled" ? "Import Cancelled" : "Import Complete"}

{importJob.status === "failed" ? (

{importJob.error || "Something went wrong while importing."}

) : importJob.status === "cancelled" ? (

Import was cancelled. No playlist was created.

) : ( <>

{importJob.tracksMatched > 0 ? `Added ${importJob.tracksMatched} songs to your playlist` : "Playlist created (songs still downloading)"}

{importJob.tracksDownloadable > 0 && importJob.tracksMatched < importJob.tracksTotal && (

{importJob.tracksDownloadable} songs still downloading

)} )}
{importJob.tracksDownloadable > 0 && importJob.tracksMatched < importJob.tracksTotal && ( )} {refreshStatusMessage && (

{refreshStatusMessage}

)} {importJob.createdPlaylistId && ( )}
)}
); } export default function SpotifyImportPage() { return (
} > ); }