Files
R3ap3r 3178b8f4f7 Aurora SpotiRip desktop and web app
- Spotify playlist, library, album and artist browsing with in-app playback
- Song, playlist and album downloads via spotdl with YouTube and Bandcamp fallbacks
- Electron desktop app (castLabs Widevine) with a permanent sidebar layout
- Linux and Windows packaging scripts
2026-09-25 23:20:57 -05:00

148 lines
4.5 KiB
TypeScript

const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
const configuredBaseUrl = (
process.env as Record<string, string | undefined>
).SPOTIRIP_BASE_URL?.trim().replace(/\/+$/, "");
export const SPOTIRIP_BASE_URL =
configuredBaseUrl || "https://spotirip.auroradragon.dev";
export const SPOTIFY_REDIRECT_URI = SPOTIRIP_BASE_URL + "/spotify/callback";
type StoredSpotifySession = {
accessToken?: string;
refreshToken?: string;
expiresAt?: number;
};
type TokenResponse = {
access_token: string;
token_type: string;
scope?: string;
expires_in: number;
refresh_token?: string;
};
export function parseCookies(request: Request) {
const raw = request.headers.get("cookie") || "";
const cookies: Record<string, string> = {};
for (const part of raw.split(";")) {
const index = part.indexOf("=");
if (index < 0) continue;
const key = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
if (key) cookies[key] = decodeURIComponent(value);
}
return cookies;
}
export function readSpotifySession(request: Request): StoredSpotifySession {
const cookies = parseCookies(request);
const expiresAt = Number(cookies.spotirip_spotify_expires || 0);
return {
accessToken: cookies.spotirip_spotify_access,
refreshToken: cookies.spotirip_spotify_refresh,
expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,
};
}
export function makeCookie(
name: string,
value: string,
options: { maxAge?: number; httpOnly?: boolean } = {},
) {
const segments = [
`${name}=${encodeURIComponent(value)}`,
"Path=/",
"Secure",
"SameSite=Lax",
];
if (options.httpOnly !== false) segments.push("HttpOnly");
if (typeof options.maxAge === "number") segments.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`);
return segments.join("; ");
}
export function clearCookie(name: string) {
return `${name}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`;
}
function spotifyBasicAuth() {
const clientId = process.env.SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error("Spotify credentials are not configured.");
}
return "Basic " + Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
}
export async function exchangeSpotifyCode(code: string) {
const response = await fetch(SPOTIFY_TOKEN_URL, {
method: "POST",
headers: {
Authorization: spotifyBasicAuth(),
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: SPOTIFY_REDIRECT_URI,
}),
});
if (!response.ok) {
const message = await response.text();
throw new Error(`Spotify token exchange failed (${response.status}): ${message.slice(0, 300)}`);
}
return (await response.json()) as TokenResponse;
}
export async function refreshSpotifyToken(refreshToken: string) {
const response = await fetch(SPOTIFY_TOKEN_URL, {
method: "POST",
headers: {
Authorization: spotifyBasicAuth(),
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const message = await response.text();
throw new Error(`Spotify token refresh failed (${response.status}): ${message.slice(0, 300)}`);
}
return (await response.json()) as TokenResponse;
}
export async function ensureSpotifyAccessToken(request: Request) {
const current = readSpotifySession(request);
const now = Date.now();
if (current.accessToken && current.expiresAt && current.expiresAt > now + 60_000) {
return { accessToken: current.accessToken, setCookies: [] as string[] };
}
if (!current.refreshToken) {
return { accessToken: null, setCookies: [] as string[] };
}
const refreshed = await refreshSpotifyToken(current.refreshToken);
const expiresAt = Date.now() + refreshed.expires_in * 1000;
const setCookies = [
makeCookie("spotirip_spotify_access", refreshed.access_token, { maxAge: refreshed.expires_in }),
makeCookie("spotirip_spotify_expires", String(expiresAt), { maxAge: refreshed.expires_in }),
];
if (refreshed.refresh_token) {
setCookies.push(
makeCookie("spotirip_spotify_refresh", refreshed.refresh_token, { maxAge: 60 * 60 * 24 * 30 }),
);
}
return { accessToken: refreshed.access_token, setCookies };
}
export function appendSetCookies(headers: Headers, cookies: string[]) {
for (const cookie of cookies) headers.append("Set-Cookie", cookie);
}