- 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
611 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|