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:
2026-09-25 23:20:57 -05:00
commit 3178b8f4f7
204 changed files with 26045 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import { z } from "zod";
import superjson from "superjson";
export const MAX_AUDIO_BYTES = 4 * 1024 * 1024;
export const schema = z.object({
file: z
.custom<File>((value) => typeof File !== "undefined" && value instanceof File, "Audio file is required")
.refine((file) => file.size > 0, "Audio file is empty")
.refine((file) => file.size <= MAX_AUDIO_BYTES, "Audio file exceeds the 80 MB limit")
.refine(
(file) =>
file.type === "audio/mpeg" ||
file.type === "audio/wav" ||
file.type === "audio/x-wav" ||
file.name.toLowerCase().endsWith(".mp3") ||
file.name.toLowerCase().endsWith(".wav"),
"Recognition snippet must be MP3 or WAV",
),
});
export type OutputType = {
matched: boolean;
title?: string;
artist?: string;
album?: string;
releaseDate?: string;
label?: string;
timecode?: string;
songLink?: string;
artworkUrl?: string;
spotifyUri?: string;
spotifyUrl?: string;
trackNumber?: number;
discNumber?: number;
isrc?: string;
};
export async function postIdentify(file: File, init?: RequestInit): Promise<OutputType> {
const form = new FormData();
form.append("file", file, file.name);
const result = await fetch("/_api/identify", {
method: "POST",
body: form,
...init,
});
if (!result.ok) {
const errorObject = superjson.parse<{ error: string }>(await result.text());
throw new Error(errorObject.error);
}
return superjson.parse<OutputType>(await result.text());
}
+101
View File
@@ -0,0 +1,101 @@
import superjson from "superjson";
import { schema, type OutputType } from "./identify_POST.schema";
type AudDResult = {
artist?: string;
title?: string;
album?: string;
release_date?: string;
label?: string;
timecode?: string;
song_link?: string;
spotify?: {
uri?: string;
track_number?: number;
disc_number?: number;
external_ids?: { isrc?: string };
external_urls?: { spotify?: string };
album?: { images?: Array<{ url?: string }> };
};
};
type AudDResponse = {
status?: string;
result?: AudDResult | null;
error?: { error_message?: string };
};
export async function handle(request: Request) {
try {
const apiToken = process.env.AUDD_API_TOKEN;
if (!apiToken) {
return new Response(
superjson.stringify({ error: "Music recognition is not connected yet." }),
{ status: 503 },
);
}
const incoming = await request.formData();
const parsed = schema.parse({ file: incoming.get("file") });
const file = parsed.file;
const outgoing = new FormData();
outgoing.append("api_token", apiToken);
outgoing.append("file", file, file.name);
outgoing.append("return", "spotify,musicbrainz");
const response = await fetch("https://api.audd.io/", {
method: "POST",
body: outgoing,
});
if (!response.ok) {
return new Response(
superjson.stringify({ error: `Recognition service returned HTTP ${response.status}.` }),
{ status: 502 },
);
}
const body = (await response.json()) as AudDResponse;
if (body.status !== "success") {
const detail = body.error?.error_message || "";
if (detail.toLowerCase().includes("audio fingerprint")) {
return new Response(superjson.stringify({ matched: false } satisfies OutputType));
}
return new Response(
superjson.stringify({
error: detail || "Recognition service could not process this file.",
}),
{ status: 502 },
);
}
if (!body.result) {
return new Response(superjson.stringify({ matched: false } satisfies OutputType));
}
const match = body.result;
const artworkUrl = match.spotify?.album?.images?.find((image) => image.url)?.url;
const result: OutputType = {
matched: true,
title: match.title,
artist: match.artist,
album: match.album,
releaseDate: match.release_date,
label: match.label,
timecode: match.timecode,
songLink: match.song_link,
artworkUrl,
spotifyUri: match.spotify?.uri,
spotifyUrl: match.spotify?.external_urls?.spotify,
trackNumber: match.spotify?.track_number,
discNumber: match.spotify?.disc_number,
isrc: match.spotify?.external_ids?.isrc,
};
return new Response(superjson.stringify(result));
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to identify audio.";
return new Response(superjson.stringify({ error: message }), { status: 400 });
}
}
+29
View File
@@ -0,0 +1,29 @@
import { z } from "zod";
import superjson from "superjson";
export const schema = z.object({
title: z.string().min(1),
artist: z.string().min(1),
album: z.string().optional(),
durationMs: z.number().nonnegative().optional(),
});
export type InputType = z.infer<typeof schema>;
export type OutputType = {
plain?: string;
synced?: string;
instrumental?: boolean;
};
export async function postLyrics(body: InputType): Promise<OutputType> {
const response = await fetch("/_api/lyrics", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: superjson.stringify(schema.parse(body)),
});
if (!response.ok) {
const parsed = superjson.parse<{ error: string }>(await response.text());
throw new Error(parsed.error);
}
return superjson.parse<OutputType>(await response.text());
}
+57
View File
@@ -0,0 +1,57 @@
import superjson from "superjson";
import { schema, type OutputType } from "./lyrics_POST.schema";
type LrclibResult = {
plainLyrics?: string | null;
syncedLyrics?: string | null;
instrumental?: boolean;
};
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function handle(request: Request) {
try {
const input = schema.parse(superjson.parse(await request.text()));
const params = new URLSearchParams({
artist_name: input.artist,
track_name: input.title,
});
if (input.album) params.set("album_name", input.album);
if (input.durationMs) {
params.set("duration", String(Math.round(input.durationMs / 1000)));
}
let response: Response | undefined;
for (let attempt = 0; attempt < 2; attempt += 1) {
response = await fetch(`https://lrclib.net/api/get?${params.toString()}`, {
headers: { "User-Agent": "Aurora-SpotiRip/1.0" },
});
if (response.status !== 429 || attempt > 0) break;
const retryAfter = Number(response.headers.get("retry-after") || 1);
await delay(Math.min(5000, Math.max(500, retryAfter * 1000)));
}
if (!response) {
throw new Error("Lyrics service did not respond.");
}
if (response.status === 404) {
return new Response(superjson.stringify({} satisfies OutputType));
}
if (!response.ok) {
return new Response(
superjson.stringify({ error: `Lyrics service returned HTTP ${response.status}.` }),
{ status: 502 },
);
}
const result = (await response.json()) as LrclibResult;
const output: OutputType = {
plain: result.plainLyrics || undefined,
synced: result.syncedLyrics || undefined,
instrumental: Boolean(result.instrumental),
};
return new Response(superjson.stringify(output));
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to load lyrics.";
return new Response(superjson.stringify({ error: message }), { status: 400 });
}
}
+41
View File
@@ -0,0 +1,41 @@
import { z } from "zod";
import superjson from "superjson";
export const schema = z.object({
title: z.string().min(1),
artist: z.string().min(1),
album: z.string().optional(),
isrc: z.string().optional(),
durationMs: z.number().nonnegative().optional(),
});
export type InputType = z.infer<typeof schema>;
export type OutputType = {
isrc?: string;
releaseDate?: string;
genre?: string;
composer?: string;
albumArtist?: string;
artworkUrl?: string;
lyrics?: {
plain?: string;
synced?: string;
};
musicBrainzRecordingId?: string;
sources: string[];
};
export async function postMetadataEnrich(body: InputType): Promise<OutputType> {
const validated = schema.parse(body);
const response = await fetch("/_api/metadata-enrich", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: superjson.stringify(validated),
});
if (!response.ok) {
const parsed = superjson.parse<{ error: string }>(await response.text());
throw new Error(parsed.error);
}
return superjson.parse<OutputType>(await response.text());
}
+218
View File
@@ -0,0 +1,218 @@
import superjson from "superjson";
import { schema, type OutputType } from "./metadata-enrich_POST.schema";
const MB_HEADERS = {
"User-Agent":
"Aurora-SpotiRip/1.0 (" +
((process.env as Record<string, string | undefined>).SPOTIRIP_BASE_URL ||
"https://spotirip.auroradragon.dev") +
")",
};
let musicBrainzQueue: Promise<unknown> = Promise.resolve();
let lastMusicBrainzRequestAt = 0;
type MusicBrainzSearch = {
recordings?: Array<{
id?: string;
isrcs?: string[];
"first-release-date"?: string;
}>;
};
type MusicBrainzRecording = {
id?: string;
isrcs?: string[];
"first-release-date"?: string;
genres?: Array<{ name?: string; count?: number }>;
tags?: Array<{ name?: string; count?: number }>;
"artist-credit"?: Array<{ artist?: { name?: string } }>;
relations?: Array<{ type?: string; work?: { id?: string } }>;
};
type MusicBrainzWork = {
relations?: Array<{ type?: string; artist?: { name?: string } }>;
};
type DeezerTrack = {
id?: number;
isrc?: string;
album?: { id?: number; cover_xl?: string; cover_big?: string };
};
type DeezerAlbum = {
genres?: { data?: Array<{ name?: string }> };
artist?: { name?: string };
cover_xl?: string;
cover_big?: string;
};
type LrclibResult = {
plainLyrics?: string | null;
syncedLyrics?: string | null;
};
function bestGenre(values: Array<{ name?: string; count?: number }> | undefined) {
return [...(values ?? [])]
.filter((entry) => entry.name)
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0))[0]?.name;
}
async function fetchJson<T>(url: string, headers?: Record<string, string>) {
const response = await fetch(url, { headers });
if (!response.ok) return undefined;
return (await response.json()) as T;
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchMusicBrainz<T>(url: string) {
const request = musicBrainzQueue.then(async () => {
const elapsed = Date.now() - lastMusicBrainzRequestAt;
if (elapsed < 1100) await delay(1100 - elapsed);
lastMusicBrainzRequestAt = Date.now();
return fetchJson<T>(url, MB_HEADERS);
});
musicBrainzQueue = request.then(
() => undefined,
() => undefined,
);
return request;
}
async function fetchLrclib<T>(url: string) {
for (let attempt = 0; attempt < 2; attempt += 1) {
const response = await fetch(url, {
headers: { "User-Agent": "Aurora-SpotiRip/1.0" },
});
if (response.status === 404) return undefined;
if (response.status === 429 && attempt === 0) {
const retryAfter = Number(response.headers.get("retry-after") || 1);
await delay(Math.min(5000, Math.max(500, retryAfter * 1000)));
continue;
}
if (!response.ok) return undefined;
return (await response.json()) as T;
}
return undefined;
}
async function findRecording(input: {
title: string;
artist: string;
isrc?: string;
}) {
const query = input.isrc
? `isrc:${input.isrc}`
: `recording:"${input.title.replace(/"/g, "")}" AND artist:"${input.artist.replace(/"/g, "")}"`;
const search = await fetchMusicBrainz<MusicBrainzSearch>(
`https://musicbrainz.org/ws/2/recording?query=${encodeURIComponent(query)}&fmt=json&limit=5`,
);
return search?.recordings?.[0];
}
export async function handle(request: Request) {
try {
const input = schema.parse(superjson.parse(await request.text()));
const sources = new Set<string>();
const output: OutputType = { sources: [] };
try {
const candidate = await findRecording(input);
if (candidate?.id) {
sources.add("MusicBrainz");
const recording = await fetchMusicBrainz<MusicBrainzRecording>(
`https://musicbrainz.org/ws/2/recording/${candidate.id}?inc=artist-credits+isrcs+work-rels+genres+tags&fmt=json`,
);
if (recording) {
output.musicBrainzRecordingId = recording.id || candidate.id;
output.isrc = input.isrc || recording.isrcs?.[0] || candidate.isrcs?.[0];
output.releaseDate =
recording["first-release-date"] || candidate["first-release-date"];
output.genre =
bestGenre(recording.genres) || bestGenre(recording.tags);
output.albumArtist = recording["artist-credit"]
?.map((entry) => entry.artist?.name)
.filter(Boolean)
.join(", ");
const workId = recording.relations?.find(
(relation) => relation.type === "performance" && relation.work?.id,
)?.work?.id;
if (workId) {
const work = await fetchMusicBrainz<MusicBrainzWork>(
`https://musicbrainz.org/ws/2/work/${workId}?inc=artist-rels&fmt=json`,
);
const composers = work?.relations
?.filter((relation) =>
["composer", "writer", "lyricist"].includes(relation.type || ""),
)
.map((relation) => relation.artist?.name)
.filter(Boolean);
if (composers?.length) {
output.composer = [...new Set(composers)].join(", ");
}
}
}
}
} catch {
}
const resolvedIsrc = output.isrc || input.isrc;
if (resolvedIsrc) {
try {
const deezerTrack = await fetchJson<DeezerTrack>(
`https://api.deezer.com/track/isrc:${encodeURIComponent(resolvedIsrc)}`,
);
if (deezerTrack?.id) {
sources.add("Deezer");
output.isrc = output.isrc || deezerTrack.isrc;
output.artworkUrl =
deezerTrack.album?.cover_xl || deezerTrack.album?.cover_big;
if (deezerTrack.album?.id) {
const album = await fetchJson<DeezerAlbum>(
`https://api.deezer.com/album/${deezerTrack.album.id}`,
);
output.genre =
output.genre || album?.genres?.data?.find((entry) => entry.name)?.name;
output.albumArtist = output.albumArtist || album?.artist?.name;
output.artworkUrl =
output.artworkUrl || album?.cover_xl || album?.cover_big;
}
}
} catch {
}
}
try {
const params = new URLSearchParams({
artist_name: input.artist,
track_name: input.title,
});
if (input.album) params.set("album_name", input.album);
if (input.durationMs) {
params.set("duration", String(Math.round(input.durationMs / 1000)));
}
const lyrics = await fetchLrclib<LrclibResult>(
`https://lrclib.net/api/get?${params.toString()}`,
);
if (lyrics?.plainLyrics || lyrics?.syncedLyrics) {
sources.add("LRCLIB");
output.lyrics = {
plain: lyrics.plainLyrics || undefined,
synced: lyrics.syncedLyrics || undefined,
};
}
} catch {
}
output.sources = [...sources];
return new Response(superjson.stringify(output));
} catch (error) {
const message =
error instanceof Error ? error.message : "Unable to enrich track metadata.";
return new Response(superjson.stringify({ error: message }), { status: 400 });
}
}
+37
View File
@@ -0,0 +1,37 @@
import superjson from "superjson";
export type SpotifyAlbumTrack = {
id: string;
name: string;
artist: string;
uri?: string;
durationMs: number;
trackNumber?: number;
discNumber?: number;
externalUrl?: string;
};
export type OutputType = {
album: {
id: string;
name: string;
artist: string;
releaseDate?: string;
totalTracks?: number;
imageUrl?: string;
externalUrl?: string;
};
tracks: SpotifyAlbumTrack[];
};
export async function getSpotifyAlbum(albumId: string): Promise<OutputType> {
const response = await fetch(
"/_api/spotify-album?albumId=" + encodeURIComponent(albumId),
{ cache: "no-store" },
);
if (!response.ok) {
const parsed = superjson.parse<{ error: string }>(await response.text());
throw new Error(parsed.error);
}
return superjson.parse<OutputType>(await response.text());
}
+144
View File
@@ -0,0 +1,144 @@
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 });
}
}
@@ -0,0 +1,41 @@
import superjson from "superjson";
export type SpotifyArtistAlbum = {
id: string;
name: string;
albumType?: string;
releaseDate?: string;
totalTracks?: number;
imageUrl?: string;
externalUrl?: string;
};
export type OutputType = {
artist: {
id: string;
name: string;
imageUrl?: string;
externalUrl?: string;
};
albums: SpotifyArtistAlbum[];
total?: number;
nextOffset?: number;
};
export async function getSpotifyArtistAlbums(
artistId: string,
offset = 0,
): Promise<OutputType> {
const params = new URLSearchParams({
artistId,
offset: String(offset),
});
const response = await fetch("/_api/spotify-artist-albums?" + params.toString(), {
cache: "no-store",
});
if (!response.ok) {
const parsed = superjson.parse<{ error: string }>(await response.text());
throw new Error(parsed.error);
}
return superjson.parse<OutputType>(await response.text());
}
+134
View File
@@ -0,0 +1,134 @@
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 });
}
}
+5
View File
@@ -0,0 +1,5 @@
export type OutputType = never;
export function getSpotifyAuthUrl() {
return "/_api/spotify-auth";
}
+43
View File
@@ -0,0 +1,43 @@
import { makeCookie, SPOTIFY_REDIRECT_URI } from "../helpers/spotifyServer";
export async function handle() {
try {
const clientId = process.env.SPOTIFY_CLIENT_ID;
if (!clientId) {
return new Response("Spotify client ID is not configured.", { status: 500 });
}
const state = crypto.randomUUID().replace(/-/g, "");
const scopes = [
"playlist-read-private",
"playlist-read-collaborative",
"streaming",
"user-read-email",
"user-read-private",
"user-read-playback-state",
"user-modify-playback-state",
"user-library-read",
"user-follow-read",
].join(" ");
const params = new URLSearchParams({
client_id: clientId,
response_type: "code",
redirect_uri: SPOTIFY_REDIRECT_URI,
scope: scopes,
state,
show_dialog: "false",
});
const headers = new Headers({
Location: `https://accounts.spotify.com/authorize?${params.toString()}`,
"Cache-Control": "no-store",
});
headers.append("Set-Cookie", makeCookie("spotirip_oauth_state", state, { maxAge: 600 }));
return new Response("Redirecting to Spotify…", { status: 302, headers });
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to start Spotify login.";
return new Response(message, { status: 500 });
}
}
+36
View File
@@ -0,0 +1,36 @@
import { createReadStream, statSync } from "node:fs";
import { Readable } from "node:stream";
import { finishDownloadJob, getDownloadJob, jsonError, requireDownloadAccess } from "../helpers/downloadJobs";
function contentDisposition(fileName: string) {
const ascii = fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(fileName)}`;
}
export async function handle(request: Request) {
try {
const access = await requireDownloadAccess(request);
if (access.error) return access.error;
const jobId = new URL(request.url).searchParams.get("jobId") || "";
const job = getDownloadJob(jobId, access.userId);
if (!job || job.status !== "ready" || !job.filePath || !job.fileName) {
return jsonError("This download is not ready or has already been collected.", 404);
}
const size = statSync(job.filePath).size;
const stream = createReadStream(job.filePath);
stream.on("close", () => finishDownloadJob(job));
return new Response(Readable.toWeb(stream) as ReadableStream, {
headers: {
"Content-Type": job.kind === "playlist" ? "application/zip" : "audio/mpeg",
"Content-Length": String(size),
"Content-Disposition": contentDisposition(job.fileName),
"Cache-Control": "no-store",
},
});
} catch (error) {
return jsonError(error instanceof Error ? error.message : "Unable to send the file.", 500);
}
}
@@ -0,0 +1,27 @@
import superjson from "superjson";
export type OutputType = {
status: "running" | "ready" | "failed";
done: number;
total: number;
failed: number;
currentTitle?: string;
error?: string;
fileName?: string;
savedPath?: string;
};
export async function getSpotifyDownloadStatus(jobId: string): Promise<OutputType> {
const response = await fetch(`/_api/spotify-download-status?jobId=${encodeURIComponent(jobId)}`, {
cache: "no-store",
});
if (!response.ok) {
const parsed = (await response.json().catch(() => ({}))) as { error?: string };
throw new Error(parsed.error || "Unable to check the download.");
}
return superjson.parse<OutputType>(await response.text());
}
export function spotifyDownloadFileUrl(jobId: string) {
return `/_api/spotify-download-file?jobId=${encodeURIComponent(jobId)}`;
}
+31
View File
@@ -0,0 +1,31 @@
import superjson from "superjson";
import type { OutputType } from "./spotify-download-status_GET.schema";
import { appendSetCookies } from "../helpers/spotifyServer";
import { getDownloadJob, jsonError, requireDownloadAccess } from "../helpers/downloadJobs";
export async function handle(request: Request) {
try {
const access = await requireDownloadAccess(request);
if (access.error) return access.error;
const jobId = new URL(request.url).searchParams.get("jobId") || "";
const job = getDownloadJob(jobId, access.userId);
if (!job) return jsonError("This download has expired or does not exist.", 404);
const output: OutputType = {
status: job.status,
done: job.done,
total: job.total,
failed: job.failed,
currentTitle: job.currentTitle,
error: job.error,
fileName: job.fileName,
savedPath: job.savedPath,
};
const headers = new Headers({ "Content-Type": "application/json", "Cache-Control": "no-store" });
appendSetCookies(headers, access.setCookies);
return new Response(superjson.stringify(output), { headers });
} catch (error) {
return jsonError(error instanceof Error ? error.message : "Unable to check the download.", 500);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { z } from "zod";
import superjson from "superjson";
const spotifyLink = z
.string()
.trim()
.regex(
/^(https:\/\/open\.spotify\.com\/(intl-[a-z-]+\/)?(track|playlist|album)\/[A-Za-z0-9]+(\?\S*)?|spotify:(track|playlist|album):[A-Za-z0-9]+)$/,
"Enter a Spotify track, album or playlist link.",
);
export const schema = z.object({
urls: z.array(spotifyLink).min(1).max(1000),
kind: z.enum(["track", "playlist"]),
name: z.string().max(200).optional(),
});
export type InputType = z.infer<typeof schema>;
export type OutputType = { jobId: string };
export async function postSpotifyDownload(body: InputType): Promise<OutputType> {
const validated = schema.parse(body);
const response = await fetch("/_api/spotify-download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: superjson.stringify(validated),
});
if (!response.ok) {
const parsed = (await response.json().catch(() => ({}))) as { error?: string };
throw new Error(parsed.error || "Unable to start the download.");
}
return superjson.parse<OutputType>(await response.text());
}
+21
View File
@@ -0,0 +1,21 @@
import superjson from "superjson";
import { schema, type OutputType } from "./spotify-download_POST.schema";
import { appendSetCookies } from "../helpers/spotifyServer";
import { jsonError, requireDownloadAccess, startDownloadJob } from "../helpers/downloadJobs";
export async function handle(request: Request) {
try {
const access = await requireDownloadAccess(request);
if (access.error) return access.error;
const parsed = schema.safeParse(superjson.parse(await request.text()));
if (!parsed.success) return jsonError(parsed.error.issues[0]?.message || "Invalid download request.", 400);
const job = startDownloadJob({ ownerId: access.userId, ...parsed.data });
const headers = new Headers({ "Content-Type": "application/json", "Cache-Control": "no-store" });
appendSetCookies(headers, access.setCookies);
return new Response(superjson.stringify({ jobId: job.id } satisfies OutputType), { headers });
} catch (error) {
return jsonError(error instanceof Error ? error.message : "Unable to start the download.", 503);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { z } from "zod";
import superjson from "superjson";
export const schema = z.object({
code: z.string().min(1),
state: z.string().min(1),
});
export type InputType = z.infer<typeof schema>;
export type OutputType = {
connected: true;
desktop?: boolean;
profile: {
id: string;
displayName: string;
email?: string;
imageUrl?: string;
product?: string;
};
};
export async function postSpotifyExchange(body: InputType): Promise<OutputType> {
const validated = schema.parse(body);
const result = await fetch("/_api/spotify-exchange", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: superjson.stringify(validated),
});
if (!result.ok) {
const parsed = superjson.parse<{ error: string }>(await result.text());
throw new Error(parsed.error);
}
return superjson.parse<OutputType>(await result.text());
}
+65
View File
@@ -0,0 +1,65 @@
import superjson from "superjson";
import { schema, type OutputType } from "./spotify-exchange_POST.schema";
import {
appendSetCookies,
clearCookie,
exchangeSpotifyCode,
makeCookie,
parseCookies,
} from "../helpers/spotifyServer";
type SpotifyProfile = {
id: string;
display_name?: string;
email?: string;
product?: string;
images?: Array<{ url?: string }>;
};
export async function handle(request: Request) {
try {
const body = schema.parse(superjson.parse(await request.text()));
const cookies = parseCookies(request);
if (!cookies.spotirip_oauth_state || cookies.spotirip_oauth_state !== body.state) {
return new Response(superjson.stringify({ error: "Spotify login state did not match. Please try connecting again." }), { status: 400 });
}
const token = await exchangeSpotifyCode(body.code);
if (!token.refresh_token) {
return new Response(superjson.stringify({ error: "Spotify did not return a refresh token. Reconnect your account." }), { status: 400 });
}
const profileResponse = await fetch("https://api.spotify.com/v1/me", {
headers: { Authorization: `Bearer ${token.access_token}` },
});
if (!profileResponse.ok) {
return new Response(superjson.stringify({ error: "Spotify login succeeded, but the user profile could not be read." }), { status: 502 });
}
const profile = (await profileResponse.json()) as SpotifyProfile;
const expiresAt = Date.now() + token.expires_in * 1000;
const headers = new Headers({ "Content-Type": "application/json", "Cache-Control": "no-store" });
appendSetCookies(headers, [
makeCookie("spotirip_spotify_access", token.access_token, { maxAge: token.expires_in }),
makeCookie("spotirip_spotify_refresh", token.refresh_token, { maxAge: 60 * 60 * 24 * 30 }),
makeCookie("spotirip_spotify_expires", String(expiresAt), { maxAge: token.expires_in }),
clearCookie("spotirip_oauth_state"),
]);
const output: OutputType = {
connected: true,
desktop: process.env.SPOTIRIP_DESKTOP === "1" || undefined,
profile: {
id: profile.id,
displayName: profile.display_name || profile.id,
email: profile.email,
imageUrl: profile.images?.find((image) => image.url)?.url,
product: profile.product,
},
};
return new Response(superjson.stringify(output), { headers });
} catch (error) {
const message = error instanceof Error ? error.message : "Spotify login failed.";
return new Response(superjson.stringify({ error: message }), { status: 400 });
}
}
+40
View File
@@ -0,0 +1,40 @@
import superjson from "superjson";
export type SpotifyLibrarySection = "tracks" | "albums" | "artists" | "shows";
export type SpotifyLibraryItem = {
id: string;
type: "track" | "album" | "artist" | "show";
name: string;
subtitle?: string;
detail?: string;
imageUrl?: string;
externalUrl?: string;
uri?: string;
durationMs?: number;
addedAt?: string;
total?: number;
};
export type OutputType = {
section: SpotifyLibrarySection;
items: SpotifyLibraryItem[];
total?: number;
nextCursor?: string;
};
export async function getSpotifyLibrary(
section: SpotifyLibrarySection,
cursor?: string,
): Promise<OutputType> {
const params = new URLSearchParams({ section });
if (cursor) params.set("cursor", cursor);
const response = await fetch("/_api/spotify-library?" + params.toString(), {
cache: "no-store",
});
if (!response.ok) {
const parsed = superjson.parse<{ error: string }>(await response.text());
throw new Error(parsed.error);
}
return superjson.parse<OutputType>(await response.text());
}
+265
View File
@@ -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 });
}
}
+10
View File
@@ -0,0 +1,10 @@
import superjson from "superjson";
export type OutputType = { connected: false };
export async function postSpotifyLogout(): Promise<OutputType> {
const result = await fetch("/_api/spotify-logout", { method: "POST" });
if (!result.ok) {
const parsed = superjson.parse<{ error: string }>(await result.text());
throw new Error(parsed.error);
}
return superjson.parse<OutputType>(await result.text());
}
+11
View File
@@ -0,0 +1,11 @@
import superjson from "superjson";
import { clearCookie } from "../helpers/spotifyServer";
export async function handle() {
const headers = new Headers({ "Content-Type": "application/json", "Cache-Control": "no-store" });
headers.append("Set-Cookie", clearCookie("spotirip_spotify_access"));
headers.append("Set-Cookie", clearCookie("spotirip_spotify_refresh"));
headers.append("Set-Cookie", clearCookie("spotirip_spotify_expires"));
headers.append("Set-Cookie", clearCookie("spotirip_oauth_state"));
return new Response(superjson.stringify({ connected: false }), { headers });
}
+47
View File
@@ -0,0 +1,47 @@
import { z } from "zod";
import superjson from "superjson";
export const schema = z.object({
playlistUrl: z.string().min(1),
});
export type InputType = z.infer<typeof schema>;
export type SpotifyPlaylistTrack = {
id: string;
uri: string;
title: string;
artist: string;
album: string;
durationMs: number;
imageUrl?: string;
externalUrl?: string;
trackNumber?: number;
discNumber?: number;
isrc?: string;
isLocal: boolean;
};
export type OutputType = {
id: string;
name: string;
description?: string;
imageUrl?: string;
owner?: string;
externalUrl?: string;
total: number;
tracks: SpotifyPlaylistTrack[];
};
export async function postSpotifyPlaylist(body: InputType): Promise<OutputType> {
const validated = schema.parse(body);
const response = await fetch("/_api/spotify-playlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: superjson.stringify(validated),
});
if (!response.ok) {
const parsed = superjson.parse<{ error: string }>(await response.text());
throw new Error(parsed.error);
}
return superjson.parse<OutputType>(await response.text());
}
+127
View File
@@ -0,0 +1,127 @@
import superjson from "superjson";
import { schema, type OutputType, type SpotifyPlaylistTrack } from "./spotify-playlist_POST.schema";
import { appendSetCookies, ensureSpotifyAccessToken } from "../helpers/spotifyServer";
type PlaylistDetails = {
id: string;
name: string;
description?: string;
owner?: { display_name?: string; id?: string };
external_urls?: { spotify?: string };
images?: Array<{ url?: string }>;
items?: { total?: number };
};
type PlaylistItem = {
is_local?: boolean;
item?: null | {
id?: string;
uri?: string;
name?: string;
duration_ms?: number;
type?: string;
external_urls?: { spotify?: string };
track_number?: number;
disc_number?: number;
external_ids?: { isrc?: string };
artists?: Array<{ name?: string }>;
album?: {
name?: string;
images?: Array<{ url?: string }>;
};
};
};
type PlaylistItemsResponse = {
items?: PlaylistItem[];
next?: string | null;
total?: number;
};
function extractPlaylistId(input: string) {
const trimmed = input.trim();
const uriMatch = trimmed.match(/^spotify:playlist:([A-Za-z0-9]+)$/);
if (uriMatch) return uriMatch[1];
const urlMatch = trimmed.match(/open\.spotify\.com\/playlist\/([A-Za-z0-9]+)/);
if (urlMatch) return urlMatch[1];
if (/^[A-Za-z0-9]{10,}$/.test(trimmed)) return trimmed;
throw new Error("Enter a valid Spotify playlist URL.");
}
async function spotifyGet<T>(url: string, accessToken: string) {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
const message = await response.text();
if (response.status === 403) {
throw new Error(
"Spotify only allows SpotiRip to read playlist items for playlists you own or collaborate on.",
);
}
throw new Error(`Spotify returned ${response.status}: ${message.slice(0, 260)}`);
}
return (await response.json()) as T;
}
export async function handle(request: Request) {
try {
const input = schema.parse(superjson.parse(await request.text()));
const playlistId = extractPlaylistId(input.playlistUrl);
const session = await ensureSpotifyAccessToken(request);
if (!session.accessToken) {
return new Response(superjson.stringify({ error: "Connect Spotify before importing a playlist." }), { status: 401 });
}
const details = await spotifyGet<PlaylistDetails>(
`https://api.spotify.com/v1/playlists/${encodeURIComponent(playlistId)}`,
session.accessToken,
);
let next: string | null = `https://api.spotify.com/v1/playlists/${encodeURIComponent(playlistId)}/items?limit=50`;
const tracks: SpotifyPlaylistTrack[] = [];
let reportedTotal = details.items?.total ?? 0;
while (next) {
const page: PlaylistItemsResponse = await spotifyGet<PlaylistItemsResponse>(next, session.accessToken);
reportedTotal = page.total ?? reportedTotal;
for (const item of page.items ?? []) {
const track = item.item;
if (!track || track.type !== "track" || !track.uri) continue;
tracks.push({
id: track.id || track.uri,
uri: track.uri,
title: track.name || "Unknown title",
artist: (track.artists ?? []).map((artist) => artist.name).filter(Boolean).join(", ") || "Unknown artist",
album: track.album?.name || "Unknown album",
durationMs: track.duration_ms || 0,
imageUrl: track.album?.images?.find((image) => image.url)?.url,
externalUrl: track.external_urls?.spotify,
trackNumber: track.track_number,
discNumber: track.disc_number,
isrc: track.external_ids?.isrc,
isLocal: Boolean(item.is_local),
});
}
next = page.next || null;
}
const output: OutputType = {
id: details.id,
name: details.name,
description: details.description,
imageUrl: details.images?.find((image) => image.url)?.url,
owner: details.owner?.display_name || details.owner?.id,
externalUrl: details.external_urls?.spotify,
total: reportedTotal || tracks.length,
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 import playlist.";
return new Response(superjson.stringify({ error: message }), { status: 400 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import superjson from "superjson";
export type SpotifyPlaylistSummary = {
id: string;
name: string;
owner?: string;
ownerId?: string;
collaborative: boolean;
imageUrl?: string;
total: number;
externalUrl?: string;
};
export type OutputType = {
playlists: SpotifyPlaylistSummary[];
};
export async function getSpotifyPlaylists(): Promise<OutputType> {
const response = await fetch("/_api/spotify-playlists", { cache: "no-store" });
if (!response.ok) {
const parsed = superjson.parse<{ error: string }>(await response.text());
throw new Error(parsed.error);
}
return superjson.parse<OutputType>(await response.text());
}
+65
View File
@@ -0,0 +1,65 @@
import superjson from "superjson";
import { appendSetCookies, ensureSpotifyAccessToken } from "../helpers/spotifyServer";
import type { OutputType, SpotifyPlaylistSummary } from "./spotify-playlists_GET.schema";
type SpotifyPlaylistPage = {
items?: Array<{
id?: string;
name?: string;
owner?: { display_name?: string; id?: string };
collaborative?: boolean;
images?: Array<{ url?: string }>;
external_urls?: { spotify?: string };
items?: { total?: number };
tracks?: { total?: number };
}>;
next?: string | null;
};
async function spotifyGet<T>(url: string, accessToken: string) {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
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;
}
export async function handle(request: Request) {
try {
const session = await ensureSpotifyAccessToken(request);
if (!session.accessToken) {
return new Response(superjson.stringify({ error: "Connect Spotify first." }), { status: 401 });
}
let next: string | null = "https://api.spotify.com/v1/me/playlists?limit=50";
const playlists: SpotifyPlaylistSummary[] = [];
while (next) {
const page: SpotifyPlaylistPage = await spotifyGet<SpotifyPlaylistPage>(next, session.accessToken);
for (const playlist of page.items ?? []) {
if (!playlist.id) continue;
playlists.push({
id: playlist.id,
name: playlist.name || "Untitled playlist",
owner: playlist.owner?.display_name || playlist.owner?.id,
ownerId: playlist.owner?.id,
collaborative: Boolean(playlist.collaborative),
imageUrl: playlist.images?.find((image) => image.url)?.url,
total: playlist.items?.total ?? playlist.tracks?.total ?? 0,
externalUrl: playlist.external_urls?.spotify,
});
}
next = page.next || null;
}
const headers = new Headers({ "Content-Type": "application/json", "Cache-Control": "no-store" });
appendSetCookies(headers, session.setCookies);
return new Response(superjson.stringify({ playlists } satisfies OutputType), { headers });
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to list Spotify playlists.";
return new Response(superjson.stringify({ error: message }), { status: 500 });
}
}
+23
View File
@@ -0,0 +1,23 @@
import superjson from "superjson";
export type OutputType = {
connected: boolean;
accessToken?: string;
canDownload?: boolean;
profile?: {
id: string;
displayName: string;
email?: string;
imageUrl?: string;
product?: string;
};
};
export async function getSpotifySession(): Promise<OutputType> {
const result = await fetch("/_api/spotify-session", { cache: "no-store" });
if (!result.ok) {
const parsed = superjson.parse<{ error: string }>(await result.text());
throw new Error(parsed.error);
}
return superjson.parse<OutputType>(await result.text());
}
+48
View File
@@ -0,0 +1,48 @@
import superjson from "superjson";
import { appendSetCookies, ensureSpotifyAccessToken } from "../helpers/spotifyServer";
import type { OutputType } from "./spotify-session_GET.schema";
import { isDownloadAllowed } from "../helpers/downloadJobs";
type SpotifyProfile = {
id: string;
display_name?: string;
email?: string;
product?: string;
images?: Array<{ url?: string }>;
};
export async function handle(request: Request) {
try {
const session = await ensureSpotifyAccessToken(request);
const headers = new Headers({ "Content-Type": "application/json", "Cache-Control": "no-store" });
appendSetCookies(headers, session.setCookies);
if (!session.accessToken) {
return new Response(superjson.stringify({ connected: false } satisfies OutputType), { headers });
}
const response = await fetch("https://api.spotify.com/v1/me", {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
if (!response.ok) {
return new Response(superjson.stringify({ connected: false } satisfies OutputType), { headers });
}
const profile = (await response.json()) as SpotifyProfile;
const output: OutputType = {
connected: true,
accessToken: session.accessToken,
canDownload: isDownloadAllowed(profile.id),
profile: {
id: profile.id,
displayName: profile.display_name || profile.id,
email: profile.email,
imageUrl: profile.images?.find((image) => image.url)?.url,
product: profile.product,
},
};
return new Response(superjson.stringify(output), { headers });
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to read Spotify session.";
return new Response(superjson.stringify({ error: message }), { status: 500 });
}
}