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

145 lines
3.9 KiB
TypeScript

import superjson from "superjson";
import {
appendSetCookies,
ensureSpotifyAccessToken,
} from "../helpers/spotifyServer";
import type {
OutputType,
SpotifyAlbumTrack,
} from "./spotify-album_GET.schema";
type ArtistRef = { name?: string };
type ImageRef = { url?: string };
type TrackObject = {
id?: string;
name?: string;
uri?: string;
duration_ms?: number;
track_number?: number;
disc_number?: number;
artists?: ArtistRef[];
external_urls?: { spotify?: string };
};
type TrackPage = {
items?: TrackObject[];
next?: string | null;
};
type SpotifyAlbum = {
id?: string;
name?: string;
release_date?: string;
total_tracks?: number;
artists?: ArtistRef[];
images?: ImageRef[];
external_urls?: { spotify?: string };
tracks?: TrackPage;
};
function artistNames(values?: ArtistRef[]) {
return (values || []).map((entry) => entry.name).filter(Boolean).join(", ");
}
function firstImage(images?: ImageRef[]) {
return images?.find((entry) => entry.url)?.url;
}
async function spotifyGet<T>(url: string, token: string) {
const response = await fetch(url, {
headers: { Authorization: "Bearer " + token },
});
if (!response.ok) {
const message = await response.text();
throw new Error(
"Spotify returned " + response.status + ": " + message.slice(0, 220),
);
}
return (await response.json()) as T;
}
function normalizeTrack(track: TrackObject): SpotifyAlbumTrack | undefined {
if (!track.id) return undefined;
return {
id: track.id,
name: track.name || "Untitled track",
artist: artistNames(track.artists) || "Unknown artist",
uri: track.uri,
durationMs: track.duration_ms || 0,
trackNumber: track.track_number,
discNumber: track.disc_number,
externalUrl: track.external_urls?.spotify,
};
}
export async function handle(request: Request) {
try {
const url = new URL(request.url);
const albumId = url.searchParams.get("albumId")?.trim();
if (!albumId) {
return new Response(
superjson.stringify({ error: "Album ID is required." }),
{ status: 400 },
);
}
const session = await ensureSpotifyAccessToken(request);
if (!session.accessToken) {
return new Response(
superjson.stringify({ error: "Connect Spotify first." }),
{ status: 401 },
);
}
const token = session.accessToken;
const album = await spotifyGet<SpotifyAlbum>(
"https://api.spotify.com/v1/albums/" + encodeURIComponent(albumId),
token,
);
if (!album.id) {
throw new Error("Spotify did not return the requested album.");
}
const tracks: SpotifyAlbumTrack[] = [];
for (const item of album.tracks?.items || []) {
const normalized = normalizeTrack(item);
if (normalized) tracks.push(normalized);
}
let next = album.tracks?.next || null;
while (next) {
const page = await spotifyGet<TrackPage>(next, token);
for (const item of page.items || []) {
const normalized = normalizeTrack(item);
if (normalized) tracks.push(normalized);
}
next = page.next || null;
}
const output: OutputType = {
album: {
id: album.id,
name: album.name || "Untitled album",
artist: artistNames(album.artists) || "Unknown artist",
releaseDate: album.release_date,
totalTracks: album.total_tracks,
imageUrl: firstImage(album.images),
externalUrl: album.external_urls?.spotify,
},
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 load Spotify album.";
return new Response(superjson.stringify({ error: message }), { status: 500 });
}
}