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

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 });
}
}