632 lines
18 KiB
TypeScript
632 lines
18 KiB
TypeScript
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 = 0;
|
|
|
|
private getPlaylistId(url: string): string {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
const pathParts = urlObj.pathname.split('/');
|
|
const playlistIndex = pathParts.findIndex((part) => part === 'playlist');
|
|
if (playlistIndex >= 0 && pathParts.length > playlistIndex + 1) {
|
|
return pathParts[playlistIndex + 1].split('?')[0];
|
|
}
|
|
throw new Error('Invalid Spotify playlist URL');
|
|
} catch (error) {
|
|
this.logger.error(`Failed to extract playlist ID: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
isTrackUrl(url: string): boolean {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
return urlObj.pathname.includes('/track/');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private getTrackId(url: string): string {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
const pathParts = urlObj.pathname.split('/');
|
|
const trackIndex = pathParts.findIndex((part) => part === 'track');
|
|
if (trackIndex >= 0 && pathParts.length > trackIndex + 1) {
|
|
return pathParts[trackIndex + 1].split('?')[0];
|
|
}
|
|
throw new Error('Invalid Spotify track URL');
|
|
} catch (error) {
|
|
this.logger.error(`Failed to extract track ID: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
isArtistUrl(url: string): boolean {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
return urlObj.pathname.includes('/artist/');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private getArtistId(url: string): string {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
const pathParts = urlObj.pathname.split('/');
|
|
const artistIndex = pathParts.findIndex((part) => part === 'artist');
|
|
if (artistIndex >= 0 && pathParts.length > artistIndex + 1) {
|
|
return pathParts[artistIndex + 1].split('?')[0];
|
|
}
|
|
throw new Error('Invalid Spotify artist URL');
|
|
} catch (error) {
|
|
this.logger.error(`Failed to extract artist ID: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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; durationMs?: number }> {
|
|
try {
|
|
this.logger.debug(`Getting track metadata for ${spotifyUrl}`);
|
|
const trackId = this.getTrackId(spotifyUrl);
|
|
const accessToken = await this.getClientCredentialsAccessToken();
|
|
|
|
const response = await fetch(
|
|
`https://api.spotify.com/v1/tracks/${trackId}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch track: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
return {
|
|
name: data.name,
|
|
artist: data.artists.map((a) => a.name).join(', '),
|
|
image: data.album.images[0]?.url || '',
|
|
durationMs: data.duration_ms,
|
|
};
|
|
} catch (error) {
|
|
this.logger.error(`Failed to get track metadata: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getPlaylistMetadata(
|
|
spotifyUrl: string,
|
|
): Promise<{ name: string; image: string }> {
|
|
try {
|
|
this.logger.debug(`Getting playlist metadata for ${spotifyUrl}`);
|
|
const detail = await getDetails(spotifyUrl);
|
|
|
|
return {
|
|
name: detail.preview.title,
|
|
image: detail.preview.image,
|
|
};
|
|
} catch (error) {
|
|
this.logger.error(`Failed to get playlist metadata: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async getClientCredentialsAccessToken(): Promise<string> {
|
|
if (this.accessToken && Date.now() < this.tokenExpiry) {
|
|
return this.accessToken;
|
|
}
|
|
|
|
try {
|
|
this.logger.debug('Getting new Spotify client credentials access token');
|
|
|
|
const response = await fetch('https://accounts.spotify.com/api/token', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: this.getBasicAuthHeader(),
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
body: 'grant_type=client_credentials',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.text();
|
|
throw new Error(`Failed to get access token: ${errorData}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
this.accessToken = data.access_token;
|
|
this.tokenExpiry = Date.now() + data.expires_in * 1000 - 60000;
|
|
|
|
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}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getAllPlaylistTracks(spotifyUrl: string): Promise<any[]> {
|
|
try {
|
|
this.logger.debug(`Getting all tracks for playlist ${spotifyUrl}`);
|
|
|
|
const playlistId = this.getPlaylistId(spotifyUrl);
|
|
this.logger.debug(`Extracted playlist ID: ${playlistId}`);
|
|
|
|
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}`,
|
|
);
|
|
|
|
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.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: {
|
|
item: {
|
|
id: string;
|
|
name: any;
|
|
artists: any[];
|
|
preview_url: any;
|
|
album: { images: any[] };
|
|
duration_ms?: number;
|
|
};
|
|
}) => {
|
|
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,
|
|
durationMs: item.item.duration_ms,
|
|
};
|
|
},
|
|
)
|
|
.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;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
async getArtistLibrary(
|
|
spotifyUrl: string,
|
|
): Promise<{ name: string; image: string; tracks: any[] }> {
|
|
try {
|
|
this.logger.debug(`Getting artist library for ${spotifyUrl}`);
|
|
const artistId = this.getArtistId(spotifyUrl);
|
|
const accessToken = await this.getUserAccessToken();
|
|
|
|
const artistResponse = await fetch(
|
|
`https://api.spotify.com/v1/artists/${artistId}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!artistResponse.ok) {
|
|
const errorText = await artistResponse.text();
|
|
throw new Error(`Failed to fetch artist metadata: ${artistResponse.status} ${errorText}`);
|
|
}
|
|
|
|
const artist = await artistResponse.json();
|
|
const albums = await this.getAllArtistAlbums(artistId, accessToken);
|
|
const tracks = await this.getTracksForAlbums(albums, accessToken, artist.name);
|
|
|
|
return {
|
|
name: `Artist Library: ${artist.name}`,
|
|
image: artist.images?.[0]?.url || '',
|
|
tracks,
|
|
};
|
|
} catch (error) {
|
|
this.logger.error(`Failed to get artist library: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async getAllArtistAlbums(artistId: string, accessToken: string): Promise<any[]> {
|
|
const includeGroups = process.env.SPOTIFY_ARTIST_INCLUDE_GROUPS || 'album,single';
|
|
const albumByKey = new Map<string, any>();
|
|
const artistAlbumPageLimit = 20;
|
|
let offset = 0;
|
|
let hasMoreAlbums = true;
|
|
|
|
while (hasMoreAlbums) {
|
|
this.logger.debug(
|
|
`Fetching artist albums from Spotify API with offset ${offset}`,
|
|
);
|
|
|
|
const albumUrl = new URL(
|
|
`https://api.spotify.com/v1/artists/${artistId}/albums`,
|
|
);
|
|
albumUrl.searchParams.set('include_groups', includeGroups);
|
|
albumUrl.searchParams.set('offset', String(offset));
|
|
|
|
this.logger.debug(`Spotify artist albums URL: ${albumUrl.toString()}`);
|
|
|
|
const response = await fetch(albumUrl.toString(), {
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to fetch artist albums: ${response.status} ${errorText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
for (const album of data.items || []) {
|
|
const key = this.normalizeLibraryKey(`${album.name}|${album.release_date}|${album.total_tracks}`);
|
|
if (!albumByKey.has(key)) {
|
|
albumByKey.set(key, album);
|
|
}
|
|
}
|
|
|
|
if (!data.next) {
|
|
hasMoreAlbums = false;
|
|
} else {
|
|
offset += artistAlbumPageLimit;
|
|
}
|
|
}
|
|
|
|
const albums = [...albumByKey.values()];
|
|
this.logger.debug(`Retrieved ${albums.length} deduplicated artist albums/singles`);
|
|
return albums;
|
|
}
|
|
|
|
private async getTracksForAlbums(
|
|
albums: any[],
|
|
accessToken: string,
|
|
artistName: string,
|
|
): Promise<any[]> {
|
|
const trackByKey = new Map<string, any>();
|
|
const maxTracks = Number(process.env.SPOTIFY_ARTIST_LIBRARY_MAX_TRACKS || 1000);
|
|
|
|
for (const album of albums) {
|
|
let offset = 0;
|
|
let hasMoreTracks = true;
|
|
|
|
while (hasMoreTracks) {
|
|
const response = await fetch(
|
|
`https://api.spotify.com/v1/albums/${album.id}/tracks?limit=50&offset=${offset}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to fetch album tracks: ${response.status} ${errorText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
for (const item of data.items || []) {
|
|
if (!item?.name || !item?.artists?.length) {
|
|
continue;
|
|
}
|
|
|
|
const fullTrack = await this.getTrackById(item.id, accessToken);
|
|
const isrc = fullTrack?.external_ids?.isrc;
|
|
const durationMs = fullTrack?.duration_ms || item.duration_ms;
|
|
const artist = item.artists.map((a) => a.name).join(', ');
|
|
const key = isrc
|
|
? `isrc:${isrc}`
|
|
: this.normalizeLibraryKey(`${artist}|${item.name}|${Math.round((durationMs || 0) / 2000)}`);
|
|
|
|
if (!trackByKey.has(key)) {
|
|
trackByKey.set(key, {
|
|
id: item.id,
|
|
name: item.name,
|
|
artist,
|
|
previewUrl: item.external_urls?.spotify || null,
|
|
coverUrl: album.images?.[0]?.url || null,
|
|
durationMs,
|
|
albumName: album.name,
|
|
primaryArtist: artistName,
|
|
});
|
|
}
|
|
|
|
if (trackByKey.size >= maxTracks) {
|
|
this.logger.warn(`Artist library max track limit reached: ${maxTracks}`);
|
|
return [...trackByKey.values()];
|
|
}
|
|
}
|
|
|
|
if (!data.next) {
|
|
hasMoreTracks = false;
|
|
} else {
|
|
offset += 50;
|
|
}
|
|
}
|
|
}
|
|
|
|
const tracks = [...trackByKey.values()];
|
|
this.logger.debug(`Retrieved ${tracks.length} deduplicated artist library tracks`);
|
|
return tracks;
|
|
}
|
|
|
|
private async getTrackById(trackId: string, accessToken: string): Promise<any> {
|
|
const response = await fetch(
|
|
`https://api.spotify.com/v1/tracks/${trackId}`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
},
|
|
);
|
|
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
private normalizeLibraryKey(value: string): string {
|
|
return value
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/&/g, ' and ')
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
}
|