Add Spotify OAuth and large playlist support

This commit is contained in:
Tor Wingalen
2026-05-14 19:40:27 +02:00
parent 0034b28f1a
commit c1fd39c377
9 changed files with 727 additions and 327 deletions
+2
View File
@@ -12,4 +12,6 @@ export enum EnvironmentEnum {
SPOOTY_AUTH_TOKEN = 'SPOOTY_AUTH_TOKEN',
CORS_ORIGIN = 'CORS_ORIGIN',
NODE_ENV = 'NODE_ENV',
SPOTIFY_REDIRECT_URI = 'SPOTIFY_REDIRECT_URI',
SPOTIFY_TOKEN_PATH = 'SPOTIFY_TOKEN_PATH',
}
+6 -1
View File
@@ -11,7 +11,12 @@ export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
if (request.path === '/api/health') {
if (
request.path === '/api/health' ||
request.path === '/api/spotify/login' ||
request.path === '/api/spotify/callback' ||
request.path === '/api/spotify/status'
) {
return true;
}
@@ -0,0 +1,38 @@
import { Controller, Get, Query, Res } from '@nestjs/common';
import type { Response } from 'express';
import { SpotifyApiService } from '../spotify-api.service';
@Controller('spotify')
export class SpotifyAuthController {
constructor(private readonly spotifyApiService: SpotifyApiService) {}
@Get('status')
async status(): Promise<{ connected: boolean }> {
return { connected: await this.spotifyApiService.hasUserToken() };
}
@Get('login')
login(@Res() res: Response): void {
res.redirect(this.spotifyApiService.getAuthorizationUrl());
}
@Get('callback')
async callback(
@Query('code') code: string | undefined,
@Query('error') error: string | undefined,
@Res() res: Response,
): Promise<void> {
if (error) {
res.redirect(`/?spotify_error=${encodeURIComponent(error)}`);
return;
}
if (!code) {
res.redirect('/?spotify_error=missing_code');
return;
}
await this.spotifyApiService.exchangeAuthorizationCode(code);
res.redirect('/?spotify=connected');
}
}
+2 -1
View File
@@ -4,11 +4,12 @@ import { ConfigModule } from '@nestjs/config';
import { SpotifyService } from './spotify.service';
import { YoutubeService } from './youtube.service';
import { SpotifyApiService } from './spotify-api.service';
import { SpotifyAuthController } from './controllers/spotify-auth.controller';
@Module({
imports: [ConfigModule],
providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
controllers: [],
controllers: [SpotifyAuthController],
exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
})
export class SharedModule {}
+253 -84
View File
@@ -1,16 +1,25 @@
import { Injectable, Logger } from '@nestjs/common';
import { resolve, dirname } from 'path';
import * as fs from 'fs';
import { EnvironmentEnum } from '../environmentEnum';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fetch = require('isomorphic-unfetch');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { getDetails } = require('spotify-url-info')(fetch);
interface StoredSpotifyUserToken {
access_token: string;
refresh_token: string;
expires_at: number;
scope?: string;
token_type?: string;
}
@Injectable()
export class SpotifyApiService {
private readonly logger = new Logger(SpotifyApiService.name);
private accessToken: string | null = null;
private tokenExpiry: number = 0;
constructor() {}
private tokenExpiry = 0;
private getPlaylistId(url: string): string {
try {
@@ -51,13 +60,179 @@ export class SpotifyApiService {
}
}
private getClientId(): string {
const clientId = process.env.SPOTIFY_CLIENT_ID;
if (!clientId) {
throw new Error('Missing SPOTIFY_CLIENT_ID');
}
return clientId;
}
private getClientSecret(): string {
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientSecret) {
throw new Error('Missing SPOTIFY_CLIENT_SECRET');
}
return clientSecret;
}
private getRedirectUri(): string {
return (
process.env[EnvironmentEnum.SPOTIFY_REDIRECT_URI] ||
'http://127.0.0.1:3000/api/spotify/callback'
);
}
private getTokenPath(): string {
if (process.env[EnvironmentEnum.SPOTIFY_TOKEN_PATH]) {
return process.env[EnvironmentEnum.SPOTIFY_TOKEN_PATH];
}
const dbPath = process.env[EnvironmentEnum.DB_PATH] || './config/db.sqlite';
return resolve(dirname(resolve(__dirname, dbPath)), 'spotify-user-token.json');
}
private getBasicAuthHeader(): string {
const credentials = Buffer.from(
`${this.getClientId()}:${this.getClientSecret()}`,
).toString('base64');
return `Basic ${credentials}`;
}
private readUserToken(): StoredSpotifyUserToken | null {
const tokenPath = this.getTokenPath();
if (!fs.existsSync(tokenPath)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(tokenPath, 'utf-8'));
} catch (error) {
this.logger.error(`Failed to read Spotify user token: ${error.message}`);
return null;
}
}
private writeUserToken(token: StoredSpotifyUserToken): void {
const tokenPath = this.getTokenPath();
fs.mkdirSync(dirname(tokenPath), { recursive: true });
fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2), {
encoding: 'utf-8',
mode: 0o600,
});
}
async hasUserToken(): Promise<boolean> {
return !!this.readUserToken();
}
getAuthorizationUrl(): string {
const url = new URL('https://accounts.spotify.com/authorize');
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', this.getClientId());
url.searchParams.set(
'scope',
[
'playlist-read-private',
'playlist-read-collaborative',
'user-read-private',
].join(' '),
);
url.searchParams.set('redirect_uri', this.getRedirectUri());
url.searchParams.set('show_dialog', 'true');
return url.toString();
}
async exchangeAuthorizationCode(code: string): Promise<void> {
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: this.getBasicAuthHeader(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: this.getRedirectUri(),
}).toString(),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Failed to exchange Spotify authorization code: ${errorData}`);
}
const data = await response.json();
this.writeUserToken({
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + data.expires_in * 1000 - 60_000,
scope: data.scope,
token_type: data.token_type,
});
this.logger.debug('Stored Spotify user access token');
}
private async refreshUserAccessToken(
token: StoredSpotifyUserToken,
): Promise<StoredSpotifyUserToken> {
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: this.getBasicAuthHeader(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: token.refresh_token,
}).toString(),
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`Failed to refresh Spotify user token: ${errorData}`);
}
const data = await response.json();
const refreshed = {
...token,
access_token: data.access_token,
refresh_token: data.refresh_token || token.refresh_token,
expires_at: Date.now() + data.expires_in * 1000 - 60_000,
scope: data.scope || token.scope,
token_type: data.token_type || token.token_type,
};
this.writeUserToken(refreshed);
return refreshed;
}
private async getUserAccessToken(): Promise<string> {
const token = this.readUserToken();
if (!token) {
throw new Error('Spotify user is not connected. Open /api/spotify/login first.');
}
if (Date.now() < token.expires_at) {
return token.access_token;
}
return (await this.refreshUserAccessToken(token)).access_token;
}
async getTrackMetadata(
spotifyUrl: string,
): Promise<{ name: string; artist: string; image: string }> {
try {
this.logger.debug(`Getting track metadata for ${spotifyUrl}`);
const trackId = this.getTrackId(spotifyUrl);
const accessToken = await this.getAccessToken();
const accessToken = await this.getClientCredentialsAccessToken();
const response = await fetch(
`https://api.spotify.com/v1/tracks/${trackId}`,
@@ -102,31 +277,18 @@ export class SpotifyApiService {
}
}
private async getAccessToken(): Promise<string> {
private async getClientCredentialsAccessToken(): Promise<string> {
if (this.accessToken && Date.now() < this.tokenExpiry) {
return this.accessToken;
}
try {
this.logger.debug('Getting new Spotify access token');
const clientId = process.env.SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientId || !clientSecret) {
throw new Error(
'Missing Spotify credentials. Set SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in .env file',
);
}
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString(
'base64',
);
this.logger.debug('Getting new Spotify client credentials access token');
const response = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
Authorization: `Basic ${credentials}`,
Authorization: this.getBasicAuthHeader(),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: 'grant_type=client_credentials',
@@ -141,7 +303,7 @@ export class SpotifyApiService {
this.accessToken = data.access_token;
this.tokenExpiry = Date.now() + data.expires_in * 1000 - 60000;
this.logger.debug('Successfully obtained Spotify access token');
this.logger.debug('Successfully obtained Spotify client credentials access token');
return this.accessToken;
} catch (error) {
this.logger.error(`Error getting Spotify access token: ${error.message}`);
@@ -155,86 +317,93 @@ export class SpotifyApiService {
const playlistId = this.getPlaylistId(spotifyUrl);
this.logger.debug(`Extracted playlist ID: ${playlistId}`);
const accessToken = await this.getAccessToken();
const accessToken = await this.getUserAccessToken();
const allTracks = [];
let offset = 0;
let hasMoreTracks = true;
while (hasMoreTracks) {
this.logger.debug(
`Fetching tracks from Spotify API with offset ${offset}`,
);
this.logger.debug(
`Fetching tracks from Spotify API with offset ${offset}`,
);
const response = await fetch(
`https://api.spotify.com/v1/playlists/${playlistId}/items?offset=${offset}&limit=100&fields=items(track(id,name,artists,preview_url,album(images))),next`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
const response = await fetch(
`https://api.spotify.com/v1/playlists/${playlistId}/items?offset=${offset}&limit=100`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);
if (!response.ok) {
const errorText = await response.text();
this.logger.error(
`Spotify API error: ${response.status} ${errorText}`,
);
if (!response.ok) {
const errorText = await response.text();
this.logger.error(
`Spotify API error: ${response.status} ${errorText}`,
if (response.status === 403) {
throw new Error(
'Spotify API refused playlist item access with 403 Forbidden. Connect Spotify through /api/spotify/login and ensure the authenticated user can access this playlist.',
);
throw new Error(`Failed to fetch tracks: ${response.status}`);
}
const data = await response.json();
if (!data.items || data.items.length === 0) {
this.logger.debug('No more tracks to fetch from Spotify API');
hasMoreTracks = false;
continue;
}
const pageTracks = data.items
.map(
(item: {
track: {
id: string;
name: any;
artists: any[];
preview_url: any;
album: { images: any[] };
};
}) => {
if (!item.track) return null;
return {
id: item.track.id,
name: item.track.name,
artist: item.track.artists.map((a) => a.name).join(', '),
previewUrl: item.track.preview_url,
coverUrl: item.track.album?.images?.[0]?.url || null,
};
},
)
.filter((track) => track !== null);
this.logger.debug(
`Retrieved ${pageTracks.length} tracks from Spotify API at offset ${offset}`,
);
if (pageTracks.length > 0) {
allTracks.push(...pageTracks);
}
if (!data.next) {
hasMoreTracks = false;
} else {
offset += 100;
}
throw new Error(`Failed to fetch tracks: ${response.status}`);
}
const data = await response.json();
if (!data.items || data.items.length === 0) {
this.logger.debug('No more tracks to fetch from Spotify API');
hasMoreTracks = false;
continue;
}
const pageTracks = data.items
.map(
(item: {
item: {
id: string;
name: any;
artists: any[];
preview_url: any;
album: { images: any[] };
};
}) => {
if (!item.item) return null;
return {
id: item.item.id,
name: item.item.name,
artist: item.item.artists.map((a) => a.name).join(', '),
previewUrl: item.item.preview_url,
coverUrl: item.item.album?.images?.[0]?.url || null,
};
},
)
.filter((track) => track !== null);
this.logger.debug(
`Total tracks retrieved from Spotify API: ${allTracks.length}`,
`Retrieved ${pageTracks.length} tracks from Spotify API at offset ${offset}`,
);
return allTracks;
if (pageTracks.length > 0) {
allTracks.push(...pageTracks);
}
if (!data.next) {
hasMoreTracks = false;
} else {
offset += 100;
}
}
this.logger.debug(
`Total tracks retrieved from Spotify API: ${allTracks.length}`,
);
return allTracks;
} catch (error) {
this.logger.error(`Failed to get all playlist tracks: ${error.message}`);
throw error;