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
+30 -2
View File
@@ -19,6 +19,7 @@ The project is based on NestJS and Angular.
### Content ### Content
- [🚀 Installation](#-installation) - [🚀 Installation](#-installation)
- [Spotify App Configuration](#spotify-app-configuration)
- [Docker](#docker) - [Docker](#docker)
- [Docker command](#docker-command) - [Docker command](#docker-command)
- [Docker compose](#docker-compose) - [Docker compose](#docker-compose)
@@ -31,6 +32,18 @@ The project is based on NestJS and Angular.
## 🚀 Installation ## 🚀 Installation
Recommended and the easiest way how to start to use of Spooty is using docker. 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 ### Docker
Just run docker command or use docker compose configuration. Just run docker command or use docker compose configuration.
@@ -38,7 +51,11 @@ For detailed configuration, see available [environment variables](#environment-v
#### Docker command #### Docker command
```shell ```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 #### Docker compose
@@ -52,6 +69,10 @@ services:
- "3000:3000" - "3000:3000"
volumes: volumes:
- /path/to/downloads:/spooty/backend/downloads - /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 ### 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` - install Node v18.19.1 using `nvm install` and use that node version `nvm use`
- from project root install all dependencies using `npm install` - 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)) - 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` - build source files `npm run build`
- built project will be stored in `dist` folder - built project will be stored in `dist` folder
- start server `npm run start` - start server `npm run start`
@@ -75,7 +101,7 @@ 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. Some behaviour and settings of Spooty can be configured using environment variables and `.env` file.
Name | Default | Description | Name | Default | Description |
----------------|---------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| ----------------------|---------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|
DB_PATH | `./config/db.sqlite` (relative to backend) | Path where Spooty database will be stored | 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 | 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 | DOWNLOADS_PATH | `./downloads` (relative to backend) | Path where downaloded files will be stored |
@@ -84,6 +110,8 @@ Some behaviour and settings of Spooty can be configured using environment variab
REDIS_PORT | 6379 | Port of Redis server | REDIS_PORT | 6379 | Port of Redis server |
REDIS_HOST | localhost | Host 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) | 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 # ⚖️ License
[MIT](https://choosealicense.com/licenses/mit/) [MIT](https://choosealicense.com/licenses/mit/)
+2 -2
View File
@@ -20494,7 +20494,7 @@
"license": "MIT" "license": "MIT"
}, },
"src/backend": { "src/backend": {
"version": "2.0.11", "version": "2.1.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@distube/ytdl-core": "^4.16.12", "@distube/ytdl-core": "^4.16.12",
@@ -20953,7 +20953,7 @@
} }
}, },
"src/frontend": { "src/frontend": {
"version": "2.0.11", "version": "2.1.1",
"dependencies": { "dependencies": {
"@angular/animations": "^19.0.6", "@angular/animations": "^19.0.6",
"@angular/common": "^19.0.6", "@angular/common": "^19.0.6",
+4
View File
@@ -6,3 +6,7 @@ PORT=3000
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_HOST=localhost 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
+4
View File
@@ -6,3 +6,7 @@ PORT=3000
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_HOST=localhost 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
+59 -3
View File
@@ -48,10 +48,14 @@ export class PlaylistService {
} }
async create(playlist: PlaylistEntity): Promise<void> { async create(playlist: PlaylistEntity): Promise<void> {
let detail; let detail: { tracks: any; name: any; image: any };
let playlist2Save: PlaylistEntity; let playlist2Save: PlaylistEntity;
try { try {
detail = await this.spotifyService.getPlaylistDetail(playlist.spotifyUrl); detail = await this.spotifyService.getPlaylistDetail(playlist.spotifyUrl);
this.logger.debug(
`Playlist detail retrieved with ${detail.tracks?.length || 0} tracks`,
);
playlist2Save = { playlist2Save = {
...playlist, ...playlist,
name: detail.name, name: detail.name,
@@ -59,18 +63,70 @@ export class PlaylistService {
}; };
this.createPlaylistFolderStructure(playlist2Save.name); this.createPlaylistFolderStructure(playlist2Save.name);
} catch (err) { } catch (err) {
this.logger.error(`Error getting playlist details: ${err}`);
playlist2Save = { ...playlist, error: String(err) }; playlist2Save = { ...playlist, error: String(err) };
} }
const savedPlaylist = await this.save(playlist2Save); const savedPlaylist = await this.save(playlist2Save);
for (const track of detail.tracks ?? []) {
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( await this.trackService.create(
{ {
artist: track.artist, artist: track.artist,
name: track.name, name: track.name,
spotifyUrl: track.previewUrl, spotifyUrl: track.previewUrl || null,
}, },
savedPlaylist, 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 { ConfigModule } from '@nestjs/config';
import { SpotifyService } from './spotify.service'; import { SpotifyService } from './spotify.service';
import { YoutubeService } from './youtube.service'; import { YoutubeService } from './youtube.service';
import { SpotifyApiService } from './spotify-api.service';
@Module({ @Module({
imports: [ConfigModule], imports: [ConfigModule],
providers: [UtilsService, SpotifyService, YoutubeService], providers: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
controllers: [], controllers: [],
exports: [UtilsService, SpotifyService, YoutubeService], exports: [UtilsService, SpotifyService, YoutubeService, SpotifyApiService],
}) })
export class SharedModule {} 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;
}
}
}
+26
View File
@@ -1,16 +1,36 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { TrackService } from '../track/track.service'; 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'); const fetch = require('isomorphic-unfetch');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { getDetails } = require('spotify-url-info')(fetch); const { getDetails } = require('spotify-url-info')(fetch);
@Injectable() @Injectable()
export class SpotifyService { export class SpotifyService {
private readonly logger = new Logger(TrackService.name); private readonly logger = new Logger(TrackService.name);
constructor(private readonly spotifyApiService: SpotifyApiService) {}
async getPlaylistDetail( async getPlaylistDetail(
spotifyUrl: string, spotifyUrl: string,
): Promise<{ name: string; tracks: any[]; image: string }> { ): Promise<{ name: string; tracks: any[]; image: string }> {
this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`); this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`);
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); const detail = await getDetails(spotifyUrl);
return { return {
name: detail.preview.title, name: detail.preview.title,
@@ -18,9 +38,15 @@ export class SpotifyService {
image: detail.preview.image, image: detail.preview.image,
}; };
} }
}
async getPlaylistTracks(spotifyUrl: string): Promise<any[]> { async getPlaylistTracks(spotifyUrl: string): Promise<any[]> {
this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`); this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`);
try {
return await this.spotifyApiService.getAllPlaylistTracks(spotifyUrl);
} catch (error) {
this.logger.error(`Error getting playlist tracks: ${error.message}`);
return (await getDetails(spotifyUrl)?.tracks) ?? []; return (await getDetails(spotifyUrl)?.tracks) ?? [];
} }
} }
}
+1 -1
View File
@@ -21,7 +21,7 @@ export class TrackEntity {
@Column() @Column()
name: string; name: string;
@Column() @Column({ nullable: true })
spotifyUrl: string; spotifyUrl: string;
@Column({ nullable: true }) @Column({ nullable: true })
@@ -17,13 +17,13 @@
</span> </span>
<span> <span>
<i class="fa-solid is-clickable hover-icon" <i class="fa-solid is-clickable hover-icon"
[ngClass]="playlist.active ? 'fa-lock-open' : 'fa-lock'" [ngClass]="playlist.active ? 'fa-toggle-on' : 'fa-toggle-off'"
[title]="playlist.active ? 'Unsubscribe from playlist changes' : 'Subscribe to playlist changes'" [title]="playlist.active ? '[ON]: Unsubscribe from playlist changes?' : '[OFF]: Subscribe to playlist changes?'"
(click)="toggleActive(playlist.id, playlist.active)" (click)="toggleActive(playlist.id, playlist.active)"
></i>&nbsp; ></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-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 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 *ngIf="!playlist.error" class="is-size-6 has-text-weight-medium">{{trackCompletedCount$ | async}}/{{trackCount$ | async}}</span>&nbsp;
</span> </span>
</p> </p>
@@ -1,5 +1,5 @@
import {Component, Input} from '@angular/core'; 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 {TrackListComponent} from "../track-list/track-list.component";
import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service"; import {PlaylistService, PlaylistStatusEnum, PlaylistUi} from "../../services/playlist.service";
import {Observable, map} from "rxjs"; import {Observable, map} from "rxjs";