- 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
135 lines
3.6 KiB
TypeScript
135 lines
3.6 KiB
TypeScript
import superjson from "superjson";
|
|
import {
|
|
appendSetCookies,
|
|
ensureSpotifyAccessToken,
|
|
} from "../helpers/spotifyServer";
|
|
import type {
|
|
OutputType,
|
|
SpotifyArtistAlbum,
|
|
} from "./spotify-artist-albums_GET.schema";
|
|
|
|
type ImageRef = { url?: string };
|
|
type SpotifyArtist = {
|
|
id?: string;
|
|
name?: string;
|
|
images?: ImageRef[];
|
|
external_urls?: { spotify?: string };
|
|
};
|
|
|
|
type SpotifyAlbum = {
|
|
id?: string;
|
|
name?: string;
|
|
album_type?: string;
|
|
release_date?: string;
|
|
total_tracks?: number;
|
|
images?: ImageRef[];
|
|
external_urls?: { spotify?: string };
|
|
};
|
|
|
|
type AlbumPage = {
|
|
items?: SpotifyAlbum[];
|
|
total?: number;
|
|
offset?: number;
|
|
limit?: number;
|
|
next?: string | null;
|
|
};
|
|
|
|
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 firstImage(images?: ImageRef[]) {
|
|
return images?.find((entry) => entry.url)?.url;
|
|
}
|
|
|
|
export async function handle(request: Request) {
|
|
try {
|
|
const url = new URL(request.url);
|
|
const artistId = url.searchParams.get("artistId")?.trim();
|
|
const offset = Math.max(0, Number(url.searchParams.get("offset") || 0) || 0);
|
|
if (!artistId) {
|
|
return new Response(
|
|
superjson.stringify({ error: "Artist 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 [artist, page] = await Promise.all([
|
|
spotifyGet<SpotifyArtist>(
|
|
"https://api.spotify.com/v1/artists/" + encodeURIComponent(artistId),
|
|
token,
|
|
),
|
|
spotifyGet<AlbumPage>(
|
|
"https://api.spotify.com/v1/artists/" +
|
|
encodeURIComponent(artistId) +
|
|
"/albums?include_groups=album,single,compilation&limit=50&offset=" +
|
|
offset,
|
|
token,
|
|
),
|
|
]);
|
|
|
|
if (!artist.id) {
|
|
throw new Error("Spotify did not return the requested artist.");
|
|
}
|
|
|
|
const seen = new Set<string>();
|
|
const albums: SpotifyArtistAlbum[] = [];
|
|
for (const album of page.items || []) {
|
|
if (!album.id || seen.has(album.id)) continue;
|
|
seen.add(album.id);
|
|
albums.push({
|
|
id: album.id,
|
|
name: album.name || "Untitled release",
|
|
albumType: album.album_type,
|
|
releaseDate: album.release_date,
|
|
totalTracks: album.total_tracks,
|
|
imageUrl: firstImage(album.images),
|
|
externalUrl: album.external_urls?.spotify,
|
|
});
|
|
}
|
|
|
|
const output: OutputType = {
|
|
artist: {
|
|
id: artist.id,
|
|
name: artist.name || "Unknown artist",
|
|
imageUrl: firstImage(artist.images),
|
|
externalUrl: artist.external_urls?.spotify,
|
|
},
|
|
albums,
|
|
total: page.total,
|
|
nextOffset: page.next
|
|
? offset + (page.limit || 50)
|
|
: undefined,
|
|
};
|
|
|
|
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 artist discography.";
|
|
return new Response(superjson.stringify({ error: message }), { status: 500 });
|
|
}
|
|
}
|