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,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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type OutputType = never;
|
||||
|
||||
export function getSpotifyAuthUrl() {
|
||||
return "/_api/spotify-auth";
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user