feat(spotify): integrate Spotify API for playlist metadata and track retrieval

This commit is contained in:
AlanCarrer
2025-10-17 20:54:44 +00:00
committed by Filip Gulán
parent 24463aa477
commit de55e42fcd
11 changed files with 357 additions and 38 deletions
+39 -11
View File
@@ -19,6 +19,7 @@ The project is based on NestJS and Angular.
### Content
- [🚀 Installation](#-installation)
- [Spotify App Configuration](#spotify-app-configuration)
- [Docker](#docker)
- [Docker command](#docker-command)
- [Docker compose](#docker-compose)
@@ -31,6 +32,18 @@ The project is based on NestJS and Angular.
## 🚀 Installation
Recommended and the easiest way how to start to use of Spooty is using docker.
### Spotify App Configuration
To fully use Spooty, you need to create an application in the Spotify Developer Dashboard:
1. Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard)
2. Sign in with your Spotify account
3. Create a new application
4. Note your `Client ID` and `Client Secret`
5. Configure the redirect URI to `http://localhost:3000/api/callback` (or the corresponding URL of your instance)
These credentials will be used by Spooty to access the Spotify API.
### Docker
Just run docker command or use docker compose configuration.
@@ -38,7 +51,11 @@ For detailed configuration, see available [environment variables](#environment-v
#### Docker command
```shell
docker run -d -p 3000:3000 -v /path/to/downloads:/spooty/backend/downloads raiper34/spooty:latest
docker run -d -p 3000:3000 \
-v /path/to/downloads:/spooty/backend/downloads \
-e SPOTIFY_CLIENT_ID=your_client_id \
-e SPOTIFY_CLIENT_SECRET=your_client_secret \
raiper34/spooty:latest
```
#### Docker compose
@@ -52,6 +69,10 @@ services:
- "3000:3000"
volumes:
- /path/to/downloads:/spooty/backend/downloads
environment:
- SPOTIFY_CLIENT_ID=your_client_id
- SPOTIFY_CLIENT_SECRET=your_client_secret
# Configure other environment variables if needed
```
### Build from source
@@ -66,6 +87,11 @@ Spooty can be also build from source files on your own.
- install Node v18.19.1 using `nvm install` and use that node version `nvm use`
- from project root install all dependencies using `npm install`
- copy `.env.default` as `.env` in `src/backend` folder and modify desired environment properties (see [environment variables](#environment-variables))
- add your Spotify application credentials to the `.env` file:
```
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
```
- build source files `npm run build`
- built project will be stored in `dist` folder
- start server `npm run start`
@@ -74,16 +100,18 @@ Spooty can be also build from source files on your own.
Some behaviour and settings of Spooty can be configured using environment variables and `.env` file.
Name | Default | Description |
----------------|---------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|
DB_PATH | `./config/db.sqlite` (relative to backend) | Path where Spooty database will be stored |
FE_PATH | `../frontend/browser` (relative to backend) | Path to frontend part of application |
DOWNLOADS_PATH | `./downloads` (relative to backend) | Path where downaloded files will be stored |
FORMAT | `mp3` | Format of downloaded files (currently fully supported only `mp3` but you can try whatever you want from [ffmpeg](https://ffmpeg.org/ffmpeg-formats.html#Muxers)) |
PORT | 3000 | Port of Spooty server |
REDIS_PORT | 6379 | Port of Redis server |
REDIS_HOST | localhost | Host of Redis server |
RUN_REDIS | false | Whenever Redis server should be started from backend (recommended for Docker environment) |
Name | Default | Description |
----------------------|---------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|
DB_PATH | `./config/db.sqlite` (relative to backend) | Path where Spooty database will be stored |
FE_PATH | `../frontend/browser` (relative to backend) | Path to frontend part of application |
DOWNLOADS_PATH | `./downloads` (relative to backend) | Path where downaloded files will be stored |
FORMAT | `mp3` | Format of downloaded files (currently fully supported only `mp3` but you can try whatever you want from [ffmpeg](https://ffmpeg.org/ffmpeg-formats.html#Muxers)) |
PORT | 3000 | Port of Spooty server |
REDIS_PORT | 6379 | Port of Redis server |
REDIS_HOST | localhost | Host of Redis server |
RUN_REDIS | false | Whenever Redis server should be started from backend (recommended for Docker environment) |
SPOTIFY_CLIENT_ID | your_client_id | Client ID of your Spotify application (required) |
SPOTIFY_CLIENT_SECRET| your_client_secret | Client Secret of your Spotify application (required) |
# ⚖️ License
[MIT](https://choosealicense.com/licenses/mit/)
+2 -2
View File
@@ -20494,7 +20494,7 @@
"license": "MIT"
},
"src/backend": {
"version": "2.0.11",
"version": "2.1.1",
"license": "UNLICENSED",
"dependencies": {
"@distube/ytdl-core": "^4.16.12",
@@ -20953,7 +20953,7 @@
}
},
"src/frontend": {
"version": "2.0.11",
"version": "2.1.1",
"dependencies": {
"@angular/animations": "^19.0.6",
"@angular/common": "^19.0.6",
+4
View File
@@ -6,3 +6,7 @@ PORT=3000
REDIS_PORT=6379
REDIS_HOST=localhost
REDIS_RUN=false
# Credentials for Spotify API
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
+4
View File
@@ -6,3 +6,7 @@ PORT=3000
REDIS_PORT=6379
REDIS_HOST=localhost
REDIS_RUN=true
# Credentials for Spotify API
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
+65 -9
View File
@@ -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 -2
View File
@@ -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;
}
}
}
+33 -7
View File
@@ -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) ?? [];
}
}
}
+1 -1
View File
@@ -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>&nbsp;
<i class="fa-solid fa-repeat is-clickable hover-icon" title="Retry download failed tracks" (click)="retryFailed(playlist.id)"></i>&nbsp;
<i class="fa-solid fa-xmark is-clickable hover-icon" title="Remove playlist from list" (click)="delete(playlist.id)"></i>&nbsp;
<i *ngIf="playlist.active" class="fa-solid fa-lock hover-icon-reversed"></i>&nbsp;
<i *ngIf="playlist.active" class="fa-solid fa-toggle-on hover-icon-reversed"></i>&nbsp;
<span *ngIf="!playlist.error" class="is-size-6 has-text-weight-medium">{{trackCompletedCount$ | async}}/{{trackCount$ | async}}</span>&nbsp;
</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";