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
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user