- 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
145 lines
3.9 KiB
TypeScript
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 });
|
|
}
|
|
}
|