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
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import superjson from "superjson";
|
||||
import { schema, type OutputType, type SpotifyPlaylistTrack } from "./spotify-playlist_POST.schema";
|
||||
import { appendSetCookies, ensureSpotifyAccessToken } from "../helpers/spotifyServer";
|
||||
|
||||
type PlaylistDetails = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
owner?: { display_name?: string; id?: string };
|
||||
external_urls?: { spotify?: string };
|
||||
images?: Array<{ url?: string }>;
|
||||
items?: { total?: number };
|
||||
};
|
||||
|
||||
type PlaylistItem = {
|
||||
is_local?: boolean;
|
||||
item?: null | {
|
||||
id?: string;
|
||||
uri?: string;
|
||||
name?: string;
|
||||
duration_ms?: number;
|
||||
type?: string;
|
||||
external_urls?: { spotify?: string };
|
||||
track_number?: number;
|
||||
disc_number?: number;
|
||||
external_ids?: { isrc?: string };
|
||||
artists?: Array<{ name?: string }>;
|
||||
album?: {
|
||||
name?: string;
|
||||
images?: Array<{ url?: string }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type PlaylistItemsResponse = {
|
||||
items?: PlaylistItem[];
|
||||
next?: string | null;
|
||||
total?: number;
|
||||
};
|
||||
|
||||
function extractPlaylistId(input: string) {
|
||||
const trimmed = input.trim();
|
||||
const uriMatch = trimmed.match(/^spotify:playlist:([A-Za-z0-9]+)$/);
|
||||
if (uriMatch) return uriMatch[1];
|
||||
const urlMatch = trimmed.match(/open\.spotify\.com\/playlist\/([A-Za-z0-9]+)/);
|
||||
if (urlMatch) return urlMatch[1];
|
||||
if (/^[A-Za-z0-9]{10,}$/.test(trimmed)) return trimmed;
|
||||
throw new Error("Enter a valid Spotify playlist URL.");
|
||||
}
|
||||
|
||||
async function spotifyGet<T>(url: string, accessToken: string) {
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
if (response.status === 403) {
|
||||
throw new Error(
|
||||
"Spotify only allows SpotiRip to read playlist items for playlists you own or collaborate on.",
|
||||
);
|
||||
}
|
||||
throw new Error(`Spotify returned ${response.status}: ${message.slice(0, 260)}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function handle(request: Request) {
|
||||
try {
|
||||
const input = schema.parse(superjson.parse(await request.text()));
|
||||
const playlistId = extractPlaylistId(input.playlistUrl);
|
||||
const session = await ensureSpotifyAccessToken(request);
|
||||
if (!session.accessToken) {
|
||||
return new Response(superjson.stringify({ error: "Connect Spotify before importing a playlist." }), { status: 401 });
|
||||
}
|
||||
|
||||
const details = await spotifyGet<PlaylistDetails>(
|
||||
`https://api.spotify.com/v1/playlists/${encodeURIComponent(playlistId)}`,
|
||||
session.accessToken,
|
||||
);
|
||||
|
||||
let next: string | null = `https://api.spotify.com/v1/playlists/${encodeURIComponent(playlistId)}/items?limit=50`;
|
||||
const tracks: SpotifyPlaylistTrack[] = [];
|
||||
let reportedTotal = details.items?.total ?? 0;
|
||||
|
||||
while (next) {
|
||||
const page: PlaylistItemsResponse = await spotifyGet<PlaylistItemsResponse>(next, session.accessToken);
|
||||
reportedTotal = page.total ?? reportedTotal;
|
||||
for (const item of page.items ?? []) {
|
||||
const track = item.item;
|
||||
if (!track || track.type !== "track" || !track.uri) continue;
|
||||
tracks.push({
|
||||
id: track.id || track.uri,
|
||||
uri: track.uri,
|
||||
title: track.name || "Unknown title",
|
||||
artist: (track.artists ?? []).map((artist) => artist.name).filter(Boolean).join(", ") || "Unknown artist",
|
||||
album: track.album?.name || "Unknown album",
|
||||
durationMs: track.duration_ms || 0,
|
||||
imageUrl: track.album?.images?.find((image) => image.url)?.url,
|
||||
externalUrl: track.external_urls?.spotify,
|
||||
trackNumber: track.track_number,
|
||||
discNumber: track.disc_number,
|
||||
isrc: track.external_ids?.isrc,
|
||||
isLocal: Boolean(item.is_local),
|
||||
});
|
||||
}
|
||||
next = page.next || null;
|
||||
}
|
||||
|
||||
const output: OutputType = {
|
||||
id: details.id,
|
||||
name: details.name,
|
||||
description: details.description,
|
||||
imageUrl: details.images?.find((image) => image.url)?.url,
|
||||
owner: details.owner?.display_name || details.owner?.id,
|
||||
externalUrl: details.external_urls?.spotify,
|
||||
total: reportedTotal || tracks.length,
|
||||
tracks,
|
||||
};
|
||||
|
||||
const headers = new Headers({ "Content-Type": "application/json", "Cache-Control": "no-store" });
|
||||
appendSetCookies(headers, session.setCookies);
|
||||
return new Response(superjson.stringify(output), { headers });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unable to import playlist.";
|
||||
return new Response(superjson.stringify({ error: message }), { status: 400 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user