feat(spotify): integrate Spotify API for playlist metadata and track retrieval
This commit is contained in:
@@ -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/)
|
||||||
|
|||||||
Generated
+2
-2
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
></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-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 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 *ngIf="!playlist.error" class="is-size-6 has-text-weight-medium">{{trackCompletedCount$ | async}}/{{trackCount$ | async}}</span>
|
||||||
</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";
|
||||||
|
|||||||
Reference in New Issue
Block a user