- 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
266 lines
7.6 KiB
TypeScript
266 lines
7.6 KiB
TypeScript
import superjson from "superjson";
|
|
import {
|
|
appendSetCookies,
|
|
ensureSpotifyAccessToken,
|
|
} from "../helpers/spotifyServer";
|
|
import type {
|
|
OutputType,
|
|
SpotifyLibraryItem,
|
|
SpotifyLibrarySection,
|
|
} from "./spotify-library_GET.schema";
|
|
|
|
type Page<T> = {
|
|
items?: T[];
|
|
next?: string | null;
|
|
total?: number;
|
|
offset?: number;
|
|
limit?: number;
|
|
};
|
|
|
|
type ArtistRef = {
|
|
name?: string;
|
|
};
|
|
|
|
type ImageRef = {
|
|
url?: string;
|
|
};
|
|
|
|
type TrackObject = {
|
|
id?: string;
|
|
name?: string;
|
|
uri?: string;
|
|
duration_ms?: number;
|
|
artists?: ArtistRef[];
|
|
album?: {
|
|
name?: string;
|
|
images?: ImageRef[];
|
|
};
|
|
external_urls?: { spotify?: string };
|
|
};
|
|
|
|
type SavedTrack = {
|
|
added_at?: string;
|
|
track?: TrackObject | null;
|
|
};
|
|
|
|
type AlbumObject = {
|
|
id?: string;
|
|
name?: string;
|
|
uri?: string;
|
|
release_date?: string;
|
|
total_tracks?: number;
|
|
artists?: ArtistRef[];
|
|
images?: ImageRef[];
|
|
external_urls?: { spotify?: string };
|
|
};
|
|
|
|
type SavedAlbum = {
|
|
added_at?: string;
|
|
album?: AlbumObject | null;
|
|
};
|
|
|
|
type ArtistObject = {
|
|
id?: string;
|
|
name?: string;
|
|
uri?: string;
|
|
genres?: string[];
|
|
images?: ImageRef[];
|
|
external_urls?: { spotify?: string };
|
|
};
|
|
|
|
type FollowedArtistsResponse = {
|
|
artists?: {
|
|
items?: ArtistObject[];
|
|
next?: string | null;
|
|
total?: number;
|
|
cursors?: { after?: string | null };
|
|
};
|
|
};
|
|
|
|
type ShowObject = {
|
|
id?: string;
|
|
name?: string;
|
|
uri?: string;
|
|
publisher?: string;
|
|
total_episodes?: number;
|
|
images?: ImageRef[];
|
|
external_urls?: { spotify?: string };
|
|
};
|
|
|
|
type SavedShow = {
|
|
added_at?: string;
|
|
show?: ShowObject | null;
|
|
};
|
|
|
|
function image(images?: ImageRef[]) {
|
|
return images?.find((entry) => entry.url)?.url;
|
|
}
|
|
|
|
function artists(values?: ArtistRef[]) {
|
|
return (values || []).map((entry) => entry.name).filter(Boolean).join(", ");
|
|
}
|
|
|
|
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();
|
|
if (response.status === 403) {
|
|
throw new Error(
|
|
"Spotify did not grant access to this library section. Disconnect and reconnect Spotify to approve the new library permissions.",
|
|
);
|
|
}
|
|
throw new Error(
|
|
"Spotify returned " + response.status + ": " + message.slice(0, 220),
|
|
);
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
function offsetCursor(page: { offset?: number; limit?: number; next?: string | null }) {
|
|
if (!page.next) return undefined;
|
|
return String((page.offset || 0) + (page.limit || 50));
|
|
}
|
|
|
|
export async function handle(request: Request) {
|
|
try {
|
|
const url = new URL(request.url);
|
|
const section = (url.searchParams.get("section") || "tracks") as SpotifyLibrarySection;
|
|
const cursor = url.searchParams.get("cursor") || undefined;
|
|
if (!["tracks", "albums", "artists", "shows"].includes(section)) {
|
|
return new Response(
|
|
superjson.stringify({ error: "Unknown Spotify library section." }),
|
|
{ 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 output: OutputType = { section, items: [] };
|
|
const limit = 50;
|
|
|
|
if (section === "tracks") {
|
|
const offset = Number(cursor || 0) || 0;
|
|
const page = await spotifyGet<Page<SavedTrack>>(
|
|
"https://api.spotify.com/v1/me/tracks?limit=" + limit + "&offset=" + offset,
|
|
token,
|
|
);
|
|
output.items = (page.items || [])
|
|
.map((entry): SpotifyLibraryItem | undefined => {
|
|
const track = entry.track;
|
|
if (!track?.id) return undefined;
|
|
return {
|
|
id: track.id,
|
|
type: "track",
|
|
name: track.name || "Untitled track",
|
|
subtitle: artists(track.artists),
|
|
detail: track.album?.name,
|
|
imageUrl: image(track.album?.images),
|
|
externalUrl: track.external_urls?.spotify,
|
|
uri: track.uri,
|
|
durationMs: track.duration_ms,
|
|
addedAt: entry.added_at,
|
|
};
|
|
})
|
|
.filter((entry): entry is SpotifyLibraryItem => Boolean(entry));
|
|
output.total = page.total;
|
|
output.nextCursor = offsetCursor(page);
|
|
} else if (section === "albums") {
|
|
const offset = Number(cursor || 0) || 0;
|
|
const page = await spotifyGet<Page<SavedAlbum>>(
|
|
"https://api.spotify.com/v1/me/albums?limit=" + limit + "&offset=" + offset,
|
|
token,
|
|
);
|
|
output.items = (page.items || [])
|
|
.map((entry): SpotifyLibraryItem | undefined => {
|
|
const album = entry.album;
|
|
if (!album?.id) return undefined;
|
|
return {
|
|
id: album.id,
|
|
type: "album",
|
|
name: album.name || "Untitled album",
|
|
subtitle: artists(album.artists),
|
|
detail: album.release_date,
|
|
imageUrl: image(album.images),
|
|
externalUrl: album.external_urls?.spotify,
|
|
uri: album.uri,
|
|
addedAt: entry.added_at,
|
|
total: album.total_tracks,
|
|
};
|
|
})
|
|
.filter((entry): entry is SpotifyLibraryItem => Boolean(entry));
|
|
output.total = page.total;
|
|
output.nextCursor = offsetCursor(page);
|
|
} else if (section === "artists") {
|
|
const after = cursor ? "&after=" + encodeURIComponent(cursor) : "";
|
|
const result = await spotifyGet<FollowedArtistsResponse>(
|
|
"https://api.spotify.com/v1/me/following?type=artist&limit=" + limit + after,
|
|
token,
|
|
);
|
|
const page = result.artists;
|
|
output.items = (page?.items || [])
|
|
.map((artist): SpotifyLibraryItem | undefined => {
|
|
if (!artist.id) return undefined;
|
|
return {
|
|
id: artist.id,
|
|
type: "artist",
|
|
name: artist.name || "Unknown artist",
|
|
subtitle: artist.genres?.slice(0, 3).join(" · "),
|
|
imageUrl: image(artist.images),
|
|
externalUrl: artist.external_urls?.spotify,
|
|
uri: artist.uri,
|
|
};
|
|
})
|
|
.filter((entry): entry is SpotifyLibraryItem => Boolean(entry));
|
|
output.total = page?.total;
|
|
output.nextCursor = page?.next
|
|
? page.cursors?.after || undefined
|
|
: undefined;
|
|
} else {
|
|
const offset = Number(cursor || 0) || 0;
|
|
const page = await spotifyGet<Page<SavedShow>>(
|
|
"https://api.spotify.com/v1/me/shows?limit=" + limit + "&offset=" + offset,
|
|
token,
|
|
);
|
|
output.items = (page.items || [])
|
|
.map((entry): SpotifyLibraryItem | undefined => {
|
|
const show = entry.show;
|
|
if (!show?.id) return undefined;
|
|
return {
|
|
id: show.id,
|
|
type: "show",
|
|
name: show.name || "Untitled show",
|
|
subtitle: show.publisher,
|
|
imageUrl: image(show.images),
|
|
externalUrl: show.external_urls?.spotify,
|
|
uri: show.uri,
|
|
addedAt: entry.added_at,
|
|
total: show.total_episodes,
|
|
};
|
|
})
|
|
.filter((entry): entry is SpotifyLibraryItem => Boolean(entry));
|
|
output.total = page.total;
|
|
output.nextCursor = offsetCursor(page);
|
|
}
|
|
|
|
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 library.";
|
|
return new Response(superjson.stringify({ error: message }), { status: 500 });
|
|
}
|
|
}
|