feat(spotify): integrate Spotify API for playlist metadata and track retrieval
This commit is contained in:
@@ -5,4 +5,8 @@ FORMAT=mp3
|
||||
PORT=3000
|
||||
REDIS_PORT=6379
|
||||
REDIS_HOST=localhost
|
||||
REDIS_RUN=false
|
||||
REDIS_RUN=false
|
||||
|
||||
# Credentials for Spotify API
|
||||
SPOTIFY_CLIENT_ID=your_client_id
|
||||
SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
|
||||
@@ -5,4 +5,8 @@ FORMAT=mp3
|
||||
PORT=3000
|
||||
REDIS_PORT=6379
|
||||
REDIS_HOST=localhost
|
||||
REDIS_RUN=true
|
||||
REDIS_RUN=true
|
||||
|
||||
# Credentials for Spotify API
|
||||
SPOTIFY_CLIENT_ID=your_client_id
|
||||
SPOTIFY_CLIENT_SECRET=your_client_secret
|
||||
|
||||
@@ -48,10 +48,14 @@ export class PlaylistService {
|
||||
}
|
||||
|
||||
async create(playlist: PlaylistEntity): Promise<void> {
|
||||
let detail;
|
||||
let detail: { tracks: any; name: any; image: any };
|
||||
let playlist2Save: PlaylistEntity;
|
||||
try {
|
||||
detail = await this.spotifyService.getPlaylistDetail(playlist.spotifyUrl);
|
||||
this.logger.debug(
|
||||
`Playlist detail retrieved with ${detail.tracks?.length || 0} tracks`,
|
||||
);
|
||||
|
||||
playlist2Save = {
|
||||
...playlist,
|
||||
name: detail.name,
|
||||
@@ -59,18 +63,70 @@ export class PlaylistService {
|
||||
};
|
||||
this.createPlaylistFolderStructure(playlist2Save.name);
|
||||
} catch (err) {
|
||||
this.logger.error(`Error getting playlist details: ${err}`);
|
||||
playlist2Save = { ...playlist, error: String(err) };
|
||||
}
|
||||
const savedPlaylist = await this.save(playlist2Save);
|
||||
for (const track of detail.tracks ?? []) {
|
||||
await this.trackService.create(
|
||||
{
|
||||
artist: track.artist,
|
||||
name: track.name,
|
||||
spotifyUrl: track.previewUrl,
|
||||
},
|
||||
savedPlaylist,
|
||||
|
||||
if (detail?.tracks && detail.tracks.length > 0) {
|
||||
this.logger.debug(
|
||||
`Starting to process ${detail.tracks.length} tracks for playlist ${savedPlaylist.name}`,
|
||||
);
|
||||
|
||||
let processedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const track of detail.tracks) {
|
||||
try {
|
||||
if (!track.artist || !track.name) {
|
||||
this.logger.warn(
|
||||
`Skipping track ${processedCount + skippedCount + 1}: Missing artist or name information`,
|
||||
);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (track.unavailable === true) {
|
||||
this.logger.warn(
|
||||
`Skipping unavailable track ${processedCount + skippedCount + 1}: ${track.artist} - ${track.name}`,
|
||||
);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.trackService.create(
|
||||
{
|
||||
artist: track.artist,
|
||||
name: track.name,
|
||||
spotifyUrl: track.previewUrl || null,
|
||||
},
|
||||
savedPlaylist,
|
||||
);
|
||||
|
||||
processedCount++;
|
||||
|
||||
if (processedCount % 100 === 0) {
|
||||
this.logger.debug(
|
||||
`Processed ${processedCount} tracks so far for playlist ${savedPlaylist.name}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Error creating track "${
|
||||
track?.artist || 'Unknown'
|
||||
} - ${track?.name || 'Unknown'}": ${error.message}`,
|
||||
);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Finished processing playlist ${savedPlaylist.name}: ` +
|
||||
`${processedCount} tracks processed, ${skippedCount} skipped, ${errorCount} errors`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(`No tracks found for playlist ${savedPlaylist.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import { UtilsService } from './utils.service';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SpotifyService } from './spotify.service';
|
||||
import { YoutubeService } from './youtube.service';
|
||||
import { SpotifyApiService } from './spotify-api.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [UtilsService, SpotifyService, YoutubeService],
|
||||
providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
||||
controllers: [],
|
||||
exports: [UtilsService, SpotifyService, YoutubeService],
|
||||
exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
|
||||
})
|
||||
export class SharedModule {}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
// 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);
|
||||
|
||||
@Injectable()
|
||||
export class SpotifyApiService {
|
||||
private readonly logger = new Logger(SpotifyApiService.name);
|
||||
private accessToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
|
||||
constructor() {}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 getAccessToken(): 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',
|
||||
);
|
||||
|
||||
const response = await fetch('https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
'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 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 detail = await getDetails(spotifyUrl);
|
||||
this.logger.debug(
|
||||
`Initial tracks count from spotify-url-info: ${detail.tracks?.length || 0}`,
|
||||
);
|
||||
|
||||
if (!detail.tracks || detail.tracks.length < 100) {
|
||||
return detail.tracks || [];
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
'Playlist has 100 or more tracks, using official Spotify API for pagination',
|
||||
);
|
||||
|
||||
const playlistId = this.getPlaylistId(spotifyUrl);
|
||||
this.logger.debug(`Extracted playlist ID: ${playlistId}`);
|
||||
|
||||
try {
|
||||
const accessToken = await this.getAccessToken();
|
||||
|
||||
const allTracks = [...detail.tracks];
|
||||
let offset = 0;
|
||||
let hasMoreTracks = true;
|
||||
|
||||
allTracks.length = 0;
|
||||
|
||||
while (hasMoreTracks) {
|
||||
this.logger.debug(
|
||||
`Fetching tracks from Spotify API with offset ${offset}`,
|
||||
);
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.spotify.com/v1/playlists/${playlistId}/tracks?offset=${offset}&limit=100&fields=items(track(name,artists,preview_url)),next`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
this.logger.error(
|
||||
`Spotify API error: ${response.status} ${errorText}`,
|
||||
);
|
||||
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: { name: any; artists: any[]; preview_url: any };
|
||||
}) => {
|
||||
if (!item.track) return null;
|
||||
|
||||
return {
|
||||
name: item.track.name,
|
||||
artist: item.track.artists.map((a) => a.name).join(', '),
|
||||
previewUrl: item.track.preview_url,
|
||||
};
|
||||
},
|
||||
)
|
||||
.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 (pageTracks.length < 100) {
|
||||
hasMoreTracks = false;
|
||||
} else {
|
||||
offset += 100;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Total tracks retrieved from Spotify API: ${allTracks.length}`,
|
||||
);
|
||||
return allTracks;
|
||||
} catch (apiError) {
|
||||
this.logger.error(
|
||||
`Failed to get tracks from Spotify API: ${apiError.message}`,
|
||||
);
|
||||
this.logger.debug('Falling back to initial tracks only');
|
||||
return detail.tracks || [];
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get all playlist tracks: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,52 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TrackService } from '../track/track.service';
|
||||
import { SpotifyApiService } from './spotify-api.service';
|
||||
// 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);
|
||||
|
||||
@Injectable()
|
||||
export class SpotifyService {
|
||||
private readonly logger = new Logger(TrackService.name);
|
||||
|
||||
constructor(private readonly spotifyApiService: SpotifyApiService) {}
|
||||
|
||||
async getPlaylistDetail(
|
||||
spotifyUrl: string,
|
||||
): Promise<{ name: string; tracks: any[]; image: string }> {
|
||||
this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`);
|
||||
const detail = await getDetails(spotifyUrl);
|
||||
return {
|
||||
name: detail.preview.title,
|
||||
tracks: detail?.tracks ?? [],
|
||||
image: detail.preview.image,
|
||||
};
|
||||
|
||||
try {
|
||||
const metadata =
|
||||
await this.spotifyApiService.getPlaylistMetadata(spotifyUrl);
|
||||
|
||||
const tracks =
|
||||
await this.spotifyApiService.getAllPlaylistTracks(spotifyUrl);
|
||||
|
||||
return {
|
||||
name: metadata.name,
|
||||
tracks: tracks || [],
|
||||
image: metadata.image,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting playlist details: ${error.message}`);
|
||||
const detail = await getDetails(spotifyUrl);
|
||||
return {
|
||||
name: detail.preview.title,
|
||||
tracks: detail?.tracks ?? [],
|
||||
image: detail.preview.image,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getPlaylistTracks(spotifyUrl: string): Promise<any[]> {
|
||||
this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`);
|
||||
return (await getDetails(spotifyUrl)?.tracks) ?? [];
|
||||
try {
|
||||
return await this.spotifyApiService.getAllPlaylistTracks(spotifyUrl);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting playlist tracks: ${error.message}`);
|
||||
return (await getDetails(spotifyUrl)?.tracks) ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export class TrackEntity {
|
||||
@Column()
|
||||
name: string;
|
||||
|
||||
@Column()
|
||||
@Column({ nullable: true })
|
||||
spotifyUrl: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
</span>
|
||||
<span>
|
||||
<i class="fa-solid is-clickable hover-icon"
|
||||
[ngClass]="playlist.active ? 'fa-lock-open' : 'fa-lock'"
|
||||
[title]="playlist.active ? 'Unsubscribe from playlist changes' : 'Subscribe to playlist changes'"
|
||||
[ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
|
||||
[title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
|
||||
(click)="toggleActive(playlist.id, playlist.active)"
|
||||
></i>
|
||||
<i class="fa-solid fa-repeat is-clickable hover-icon" title="Retry download failed tracks" (click)="retryFailed(playlist.id)"></i>
|
||||
<i class="fa-solid fa-xmark is-clickable hover-icon" title="Remove playlist from list" (click)="delete(playlist.id)"></i>
|
||||
<i *ngIf="playlist.active" class="fa-solid fa-lock hover-icon-reversed"></i>
|
||||
<i *ngIf="playlist.active" class="fa-solid fa-toggle-on hover-icon-reversed"></i>
|
||||
<span *ngIf="!playlist.error" class="is-size-6 has-text-weight-medium">{{trackCompletedCount$ | async}}/{{trackCount$ | async}}</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Component, Input} from '@angular/core';
|
||||
import {AsyncPipe, CommonModule, NgForOf, NgIf} from "@angular/common";
|
||||
import {AsyncPipe, CommonModule, NgIf} from "@angular/common";
|
||||
import {TrackListComponent} from "../track-list/track-list.component";
|
||||
import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service";
|
||||
import {Observable, map} from "rxjs";
|
||||
|
||||
Reference in New Issue
Block a user