From de55e42fcd246d86e8d3156d8e0ab4898b5755d6 Mon Sep 17 00:00:00 2001 From: AlanCarrer Date: Tue, 9 Sep 2025 15:04:05 +0200 Subject: [PATCH] feat(spotify): integrate Spotify API for playlist metadata and track retrieval --- README.md | 50 ++++- package-lock.json | 4 +- src/backend/.env.default | 6 +- src/backend/.env.docker | 6 +- src/backend/src/playlist/playlist.service.ts | 74 ++++++- src/backend/src/shared/shared.module.ts | 5 +- src/backend/src/shared/spotify-api.service.ts | 200 ++++++++++++++++++ src/backend/src/shared/spotify.service.ts | 40 +++- src/backend/src/track/track.entity.ts | 2 +- .../playlist-box/playlist-box.component.html | 6 +- .../playlist-box/playlist-box.component.ts | 2 +- 11 files changed, 357 insertions(+), 38 deletions(-) create mode 100644 src/backend/src/shared/spotify-api.service.ts diff --git a/README.md b/README.md index 77ef1d3..f148fea 100644 --- a/README.md +++ b/README.md @@ -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/) diff --git a/package-lock.json b/package-lock.json index f47f53d..0f4707d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/backend/.env.default b/src/backend/.env.default index 880bbab..9694f7a 100644 --- a/src/backend/.env.default +++ b/src/backend/.env.default @@ -5,4 +5,8 @@ FORMAT=mp3 PORT=3000 REDIS_PORT=6379 REDIS_HOST=localhost -REDIS_RUN=false \ No newline at end of file +REDIS_RUN=false + +# Credentials for Spotify API +SPOTIFY_CLIENT_ID=your_client_id +SPOTIFY_CLIENT_SECRET=your_client_secret diff --git a/src/backend/.env.docker b/src/backend/.env.docker index 810e842..20ab21a 100644 --- a/src/backend/.env.docker +++ b/src/backend/.env.docker @@ -5,4 +5,8 @@ FORMAT=mp3 PORT=3000 REDIS_PORT=6379 REDIS_HOST=localhost -REDIS_RUN=true \ No newline at end of file +REDIS_RUN=true + +# Credentials for Spotify API +SPOTIFY_CLIENT_ID=your_client_id +SPOTIFY_CLIENT_SECRET=your_client_secret diff --git a/src/backend/src/playlist/playlist.service.ts b/src/backend/src/playlist/playlist.service.ts index e727d19..8b96a6b 100644 --- a/src/backend/src/playlist/playlist.service.ts +++ b/src/backend/src/playlist/playlist.service.ts @@ -48,10 +48,14 @@ export class PlaylistService { } async create(playlist: PlaylistEntity): Promise { - 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}`); } } diff --git a/src/backend/src/shared/shared.module.ts b/src/backend/src/shared/shared.module.ts index ab9028f..752e8e9 100644 --- a/src/backend/src/shared/shared.module.ts +++ b/src/backend/src/shared/shared.module.ts @@ -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 {} diff --git a/src/backend/src/shared/spotify-api.service.ts b/src/backend/src/shared/spotify-api.service.ts new file mode 100644 index 0000000..08047d1 --- /dev/null +++ b/src/backend/src/shared/spotify-api.service.ts @@ -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 { + 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 { + 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; + } + } +} diff --git a/src/backend/src/shared/spotify.service.ts b/src/backend/src/shared/spotify.service.ts index 89e1cee..65b4c1f 100644 --- a/src/backend/src/shared/spotify.service.ts +++ b/src/backend/src/shared/spotify.service.ts @@ -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 { 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) ?? []; + } } } diff --git a/src/backend/src/track/track.entity.ts b/src/backend/src/track/track.entity.ts index a997334..39e4dea 100644 --- a/src/backend/src/track/track.entity.ts +++ b/src/backend/src/track/track.entity.ts @@ -21,7 +21,7 @@ export class TrackEntity { @Column() name: string; - @Column() + @Column({ nullable: true }) spotifyUrl: string; @Column({ nullable: true }) diff --git a/src/frontend/src/app/components/playlist-box/playlist-box.component.html b/src/frontend/src/app/components/playlist-box/playlist-box.component.html index 5887649..b11683c 100644 --- a/src/frontend/src/app/components/playlist-box/playlist-box.component.html +++ b/src/frontend/src/app/components/playlist-box/playlist-box.component.html @@ -17,13 +17,13 @@       -   +   {{trackCompletedCount$ | async}}/{{trackCount$ | async}} 

diff --git a/src/frontend/src/app/components/playlist-box/playlist-box.component.ts b/src/frontend/src/app/components/playlist-box/playlist-box.component.ts index 6c2fc4d..50a072c 100644 --- a/src/frontend/src/app/components/playlist-box/playlist-box.component.ts +++ b/src/frontend/src/app/components/playlist-box/playlist-box.component.ts @@ -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";