Files
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
2026-09-25 23:20:57 -05:00

611 lines
22 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import {
AudioLines,
ChevronLeft,
ChevronRight,
Download,
ExternalLink,
FolderOpen,
ListMusic,
Loader2,
Music2,
Settings2,
Unplug,
} from "lucide-react";
import { Button } from "../components/Button";
import { Input } from "../components/Input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../components/Select";
import { SpotifyPlayerBar } from "../components/SpotifyPlayerBar";
import { LyricsPanel } from "../components/LyricsPanel";
import { MobileNav } from "../components/MobileNav";
import {
getSpotifyPlaylists,
type SpotifyPlaylistSummary,
} from "../endpoints/spotify-playlists_GET.schema";
import { getSpotifySession, type OutputType as SpotifySession } from "../endpoints/spotify-session_GET.schema";
import { postSpotifyLogout } from "../endpoints/spotify-logout_POST.schema";
import { postSpotifyPlaylist, type OutputType as SpotifyPlaylist } from "../endpoints/spotify-playlist_POST.schema";
import { postLyrics, type OutputType as LyricsResult } from "../endpoints/lyrics_POST.schema";
import {
loadOutputDirectory,
saveOutputDirectory,
} from "../helpers/directoryStore";
import type { WritableDirectoryHandle } from "../helpers/mp3Tags";
import { useSpotifyPlayer } from "../helpers/useSpotifyPlayer";
import { useSpotifyDownload } from "../helpers/useSpotifyDownload";
import { addLibraryHistory } from "../helpers/libraryHistory";
import { findDuplicateIdentities } from "../helpers/libraryIdentity";
import styles from "./_index.module.css";
type Track = {
id: number;
title: string;
artist: string;
album: string;
duration: string;
durationMs: number;
uri?: string;
imageUrl?: string;
externalUrl?: string;
isLocal?: boolean;
isrc?: string;
duplicate?: boolean;
};
const placeholderTracks: Track[] = [
{
id: 1,
title: "Connect Spotify",
artist: "Then paste a playlist URL above",
album: "Aurora SpotiRip",
duration: "0:00",
durationMs: 0,
},
];
function formatTime(ms: number) {
const total = Math.max(0, Math.floor(ms / 1000));
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`;
}
export default function HomePage() {
const [playlistUrl, setPlaylistUrl] = useState("");
const [directoryName, setDirectoryName] = useState("Music / Aurora Exports");
const [tracks, setTracks] = useState<Track[]>(placeholderTracks);
const [spotify, setSpotify] = useState<SpotifySession>({ connected: false });
const [playlist, setPlaylist] = useState<SpotifyPlaylist>();
const [availablePlaylists, setAvailablePlaylists] = useState<SpotifyPlaylistSummary[]>([]);
const [trackPage, setTrackPage] = useState(0);
const [trackFilter, setTrackFilter] = useState("");
const [loadingPlaylist, setLoadingPlaylist] = useState(false);
const [message, setMessage] = useState<string>();
const [error, setError] = useState<string>();
const [lyricsOpen, setLyricsOpen] = useState(false);
const [lyricsLoading, setLyricsLoading] = useState(false);
const [lyrics, setLyrics] = useState<LyricsResult>();
const [lyricsError, setLyricsError] = useState<string>();
const player = useSpotifyPlayer(spotify.accessToken);
const downloads = useSpotifyDownload();
const canDownload = Boolean(spotify.connected && spotify.canDownload);
const downloadableTracks = tracks.filter((track) => track.externalUrl && !track.isLocal);
const pageSize = 20;
const normalizedFilter = trackFilter.trim().toLowerCase();
const filteredTracks = normalizedFilter
? tracks.filter((track) =>
[track.title, track.artist, track.album].some((value) =>
value.toLowerCase().includes(normalizedFilter),
),
)
: tracks;
const pageCount = Math.max(1, Math.ceil(filteredTracks.length / pageSize));
const visibleTracks = filteredTracks.slice(
trackPage * pageSize,
trackPage * pageSize + pageSize,
);
useEffect(() => {
getSpotifySession()
.then(async (session) => {
setSpotify(session);
if (session.connected) {
try {
const result = await getSpotifyPlaylists();
setAvailablePlaylists(
result.playlists.filter(
(entry) => entry.ownerId === session.profile?.id || entry.collaborative,
),
);
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Unable to list your Spotify playlists.",
);
}
}
})
.catch((reason) =>
setError(
reason instanceof Error
? reason.message
: "Unable to read Spotify connection.",
),
);
}, []);
useEffect(() => {
loadOutputDirectory()
.then((handle) => {
if (handle?.name) setDirectoryName(handle.name);
})
.catch(() => undefined);
}, []);
useEffect(() => {
if (!lyricsOpen || !player.track?.title || !player.track.artist) return;
let cancelled = false;
setLyricsLoading(true);
setLyricsError(undefined);
setLyrics(undefined);
postLyrics({
title: player.track.title,
artist: player.track.artist,
album: player.track.album || undefined,
})
.then((result) => {
if (!cancelled) setLyrics(result);
})
.catch((reason) => {
if (!cancelled) {
setLyricsError(reason instanceof Error ? reason.message : "Unable to load lyrics.");
}
})
.finally(() => {
if (!cancelled) setLyricsLoading(false);
});
return () => {
cancelled = true;
};
}, [lyricsOpen, player.track?.uri, player.track?.title, player.track?.artist, player.track?.album]);
async function chooseFolder() {
const picker = (window as typeof window & {
showDirectoryPicker?: () => Promise<WritableDirectoryHandle>;
}).showDirectoryPicker;
if (!picker) {
setDirectoryName("Browser downloads folder");
return;
}
try {
const handle = await picker();
setDirectoryName(handle.name || "Selected folder");
await saveOutputDirectory(handle);
} catch {
}
}
async function importPlaylist(source = playlistUrl) {
const target = source.trim();
if (!target) return;
if (!spotify.connected) {
setError("Connect Spotify before importing a playlist.");
return;
}
setLoadingPlaylist(true);
setError(undefined);
setMessage(undefined);
try {
const imported = await postSpotifyPlaylist({ playlistUrl: target });
const duplicateIndexes = findDuplicateIdentities(
imported.tracks.map((track) => ({
isrc: track.isrc,
title: track.title,
artist: track.artist,
album: track.album,
})),
);
setPlaylist(imported);
setTrackPage(0);
setTracks(
imported.tracks.map((track, index) => ({
id: index + 1,
title: track.title,
artist: track.artist,
album: track.album,
duration: formatTime(track.durationMs),
durationMs: track.durationMs,
uri: track.uri,
imageUrl: track.imageUrl,
externalUrl: track.externalUrl,
isLocal: track.isLocal,
isrc: track.isrc,
duplicate: duplicateIndexes.has(index),
})),
);
setMessage(`Loaded ${imported.tracks.length} tracks from “${imported.name}”.`);
setPlaylistUrl(target);
addLibraryHistory({
kind: "playlist",
title: imported.name,
trackCount: imported.tracks.length,
artworkUrl: imported.imageUrl,
sourceUrl: imported.externalUrl,
});
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Playlist import failed.");
} finally {
setLoadingPlaylist(false);
}
}
async function disconnectSpotify() {
try {
await postSpotifyLogout();
setSpotify({ connected: false });
setPlaylist(undefined);
setAvailablePlaylists([]);
setTracks(placeholderTracks);
setTrackPage(0);
setMessage("Spotify disconnected.");
setError(undefined);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Unable to disconnect Spotify.");
}
}
async function playTrack(track: Track) {
if (!track.uri) return;
try {
setError(undefined);
await player.playUri(track.uri);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Unable to start Spotify playback.");
}
}
return (
<div className={styles.appShell}>
<main className={styles.main}>
<header className={styles.topbar}>
<Link to="/" className={styles.mobileBrand} aria-label="Aurora SpotiRip home">
<img
src="/SpotiRip.png"
alt=""
/>
</Link>
<div>
<span className={styles.sectionKicker}>PLAYLIST WORKBENCH</span>
<h1>Library Queue</h1>
</div>
<div className={styles.topbarActions}>
{spotify.connected ? (
<div className={styles.account}>
{spotify.profile?.imageUrl ? <img src={spotify.profile.imageUrl} alt="" /> : null}
<div>
<strong>{spotify.profile?.displayName || "Spotify connected"}</strong>
<span>{spotify.profile?.product === "premium" ? "Premium" : spotify.profile?.product || "Spotify"}</span>
</div>
<Button variant="ghost" size="icon-sm" onClick={disconnectSpotify} aria-label="Disconnect Spotify">
<Unplug size={15} />
</Button>
</div>
) : (
<Button size="sm" asChild><a href="/_api/spotify-auth">Connect Spotify</a></Button>
)}
<Button variant="ghost" size="icon" aria-label="Settings" asChild><Link to="/settings"><Settings2 size={18} /></Link></Button>
</div>
</header>
<section className={styles.sourceStrip}>
<div className={styles.sourceLabel}>
<Music2 size={17} />
<div>
<span>SPOTIFY SOURCE</span>
<strong>Import a playlist</strong>
</div>
</div>
<div className={styles.sourceInput}>
{spotify.connected && availablePlaylists.length ? (
<Select
onValueChange={(value) =>
importPlaylist(`https://open.spotify.com/playlist/${value}`)
}
>
<SelectTrigger aria-label="Choose one of my Spotify playlists">
<SelectValue placeholder="My playlists" />
</SelectTrigger>
<SelectContent>
{availablePlaylists.map((entry) => (
<SelectItem key={entry.id} value={entry.id}>
{entry.name} ({entry.total})
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Input
value={playlistUrl}
onChange={(event) => setPlaylistUrl(event.target.value)}
placeholder="Paste Spotify playlist URL"
aria-label="Spotify playlist URL"
/>
<Button onClick={() => importPlaylist()} disabled={loadingPlaylist}>
{loadingPlaylist ? "Loading…" : "Load"}
</Button>
</div>
<div className={styles.sourceMeta}>
<span>{playlist ? playlist.total : 0} tracks</span>
<span>
{player.ready
? "Player ready"
: spotify.connected
? "Connecting player"
: "Spotify offline"}
</span>
</div>
</section>
{(error || message || player.error) && (
<div className={error || player.error ? styles.noticeError : styles.notice}>
{error || player.error || message}
</div>
)}
<section className={styles.playlistHero}>
<div className={styles.coverPanel}>
<div className={styles.coverCrop}>
<img
src={playlist?.imageUrl || "/SpotiRip.png"}
alt=""
/>
</div>
<div className={styles.coverGlow} />
</div>
<div className={styles.playlistSummary}>
<span className={styles.sectionKicker}>SPOTIFY PLAYLIST</span>
<h2>{playlist?.name || "Import a playlist"}</h2>
<p>
{playlist?.description ||
"Connect Spotify, paste a playlist URL, and SpotiRip will load the real track list and artwork here."}
</p>
{playlist?.externalUrl ? (
<a
className={styles.spotifyAttribution}
href={playlist.externalUrl}
target="_blank"
rel="noreferrer"
>
Spotify <ExternalLink size={12} /> OPEN SPOTIFY
</a>
) : null}
<div className={styles.waveform} aria-hidden="true">
{[18, 34, 48, 26, 60, 42, 72, 54, 30, 66, 44, 80, 52, 28, 62, 38, 74, 46, 24, 56, 36, 68, 40, 22].map((height, index) => (
<span key={index} style={{ height: height + "%" }} />
))}
</div>
<div className={styles.playlistStats}>
<div><strong>{playlist ? playlist.tracks.length : 0}</strong><span>Tracks</span></div>
<div><strong>{playlist?.owner || "—"}</strong><span>Owner</span></div>
<div><strong>{player.ready ? "Ready" : "—"}</strong><span>Web player</span></div>
</div>
</div>
<div className={styles.quickPanel}>
<span className={styles.sectionKicker}>QUICK ACTIONS</span>
<button className={styles.quickAction} onClick={chooseFolder}>
<FolderOpen size={16} />
<div><span>Destination</span><strong>{directoryName}</strong></div>
</button>
<Link className={styles.quickAction} to="/identify">
<AudioLines size={16} />
<div><span>Audio tools</span><strong>Identify unknown files</strong></div>
</Link>
<Link className={styles.quickAction} to="/convert">
<Download size={16} />
<div><span>Audio tools</span><strong>Convert local audio to MP3</strong></div>
</Link>
</div>
</section>
<section id="library" className={styles.libraryPanel}>
<div className={styles.libraryToolbar}>
<div className={styles.libraryTitle}>
<div className={styles.albumMark}><ListMusic size={21} /></div>
<div>
<span>CURRENT QUEUE</span>
<strong>{playlist?.name || "No playlist loaded"}</strong>
{playlist?.owner ? <span className={styles.owner}>by {playlist.owner}</span> : null}
</div>
</div>
<div className={styles.libraryActions}>
<Input
className={styles.librarySearch}
value={trackFilter}
onChange={(event) => {
setTrackFilter(event.target.value);
setTrackPage(0);
}}
placeholder="Filter tracks"
aria-label="Filter playlist tracks"
/>
<Button variant="outline" size="sm" asChild><Link to="/identify"><AudioLines size={15} /> Identify</Link></Button>
<Button size="sm" asChild><Link to="/convert"><Download size={15} /> Convert</Link></Button>
{canDownload && playlist && downloadableTracks.length > 0 ? (
<Button
size="sm"
disabled={downloads.busyKeys.has("playlist")}
onClick={() =>
downloads.startDownload("playlist", `“${playlist.name}”`, {
kind: "playlist",
name: playlist.name,
urls: downloadableTracks.map((track) => track.externalUrl!),
})
}
>
{downloads.busyKeys.has("playlist") ? <Loader2 size={15} className={styles.spin} /> : <Download size={15} />}
Download playlist (.zip)
</Button>
) : null}
</div>
</div>
<div className={styles.trackTable}>
<div className={styles.tableHeader}>
<span>#</span>
<span>Title</span>
<span>Album</span>
<span>Time</span>
<span>Status</span>
</div>
{visibleTracks.map((track) => (
<div
className={styles.trackRow}
key={track.id}
role={track.uri ? "button" : undefined}
tabIndex={track.uri ? 0 : -1}
onClick={() => playTrack(track)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
playTrack(track);
}
}}
>
<span className={styles.trackNo}>{String(track.id).padStart(2, "0")}</span>
<div className={styles.trackMain}>
<div className={styles.trackThumb}>
{track.imageUrl ? <img src={track.imageUrl} alt="" /> : <Music2 size={16} />}
</div>
<div>
<strong>{track.title}</strong>
<span>{track.artist}</span>
</div>
</div>
<span className={styles.album}>{track.album}</span>
<span className={styles.duration}>{track.duration}</span>
<div className={styles.rowEnd}>
<span className={styles.trackStatus}>
{track.duplicate ? "Duplicate" : track.isLocal ? "Local item" : track.isrc ? "ISRC" : "Spotify"}
</span>
{canDownload && track.externalUrl && !track.isLocal ? (
<button
className={styles.rowDownload}
disabled={downloads.busyKeys.has(track.externalUrl)}
aria-label={`Download ${track.title} as MP3`}
title="Download MP3"
onClick={(event) => {
event.stopPropagation();
downloads.startDownload(track.externalUrl!, `“${track.title}”`, {
kind: "track",
urls: [track.externalUrl!],
});
}}
onKeyDown={(event) => event.stopPropagation()}
>
{downloads.busyKeys.has(track.externalUrl) ? <Loader2 size={13} className={styles.spin} /> : <Download size={13} />}
</button>
) : null}
{track.externalUrl ? (
<a
href={track.externalUrl}
target="_blank"
rel="noreferrer"
aria-label={`Open ${track.title} in Spotify`}
onClick={(event) => event.stopPropagation()}
>
<ExternalLink size={13} />
</a>
) : null}
</div>
</div>
))}
</div>
{playlist && pageCount > 1 ? (
<div className={styles.pagination}>
<Button
variant="ghost"
size="sm"
disabled={trackPage === 0}
onClick={() => setTrackPage((page) => Math.max(0, page - 1))}
>
<ChevronLeft size={14} /> Previous
</Button>
<span>Page {trackPage + 1} of {pageCount}</span>
<Button
variant="ghost"
size="sm"
disabled={trackPage >= pageCount - 1}
onClick={() => setTrackPage((page) => Math.min(pageCount - 1, page + 1))}
>
Next <ChevronRight size={14} />
</Button>
</div>
) : null}
{playlist && filteredTracks.length === 0 ? (
<div className={styles.noResults}>No tracks match “{trackFilter}”.</div>
) : null}
</section>
<footer className={styles.statusbar}>
<span>{spotify.connected ? "Spotify connected" : "Spotify disconnected"}</span>
<span>{directoryName}</span>
<span>ID3 + filename normalization</span>
</footer>
</main>
<div id="player" className={styles.playerDock}>
<SpotifyPlayerBar
connected={spotify.connected}
ready={player.ready}
trackTitle={player.track?.title}
artist={player.track?.artist}
album={player.track?.album}
imageUrl={player.track?.imageUrl}
externalUrl={player.track?.externalUrl}
isPlaying={player.isPlaying}
positionMs={player.positionMs}
durationMs={player.durationMs}
volume={player.volume}
error={player.error}
lyricsOpen={lyricsOpen}
onToggleLyrics={() => setLyricsOpen((open) => !open)}
onTogglePlay={() => player.togglePlay()}
onPrevious={() => player.previousTrack()}
onNext={() => player.nextTrack()}
onSeek={(position) => player.seek(position)}
onVolume={(volume) => player.setVolume(volume)}
/>
</div>
{lyricsOpen ? (
<LyricsPanel
title={player.track?.title}
artist={player.track?.artist}
plain={lyrics?.plain}
synced={lyrics?.synced}
instrumental={lyrics?.instrumental}
positionMs={player.positionMs}
loading={lyricsLoading}
error={lyricsError}
onClose={() => setLyricsOpen(false)}
/>
) : null}
<MobileNav />
</div>
);
}