- 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
148 lines
4.5 KiB
TypeScript
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);
|
|
}
|