386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { Repository } from 'typeorm';
|
|
import { PlaylistEntity } from './playlist.entity';
|
|
import { TrackService } from '../track/track.service';
|
|
import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
|
import { Server } from 'socket.io';
|
|
import * as fs from 'fs';
|
|
import { Interval } from '@nestjs/schedule';
|
|
import { TrackStatusEnum } from '../track/track.entity';
|
|
import { UtilsService } from '../shared/utils.service';
|
|
import { SpotifyService } from '../shared/spotify.service';
|
|
import { toSafeErrorMessage } from '../shared/errors/safe-error';
|
|
|
|
enum WsPlaylistOperation {
|
|
New = 'playlistNew',
|
|
Update = 'playlistUpdate',
|
|
Delete = 'playlistDelete',
|
|
}
|
|
|
|
@WebSocketGateway()
|
|
@Injectable()
|
|
export class PlaylistService {
|
|
@WebSocketServer() io: Server;
|
|
private readonly logger = new Logger(TrackService.name);
|
|
|
|
constructor(
|
|
@InjectRepository(PlaylistEntity)
|
|
private repository: Repository<PlaylistEntity>,
|
|
private readonly trackService: TrackService,
|
|
private readonly utilsService: UtilsService,
|
|
private readonly spotifyService: SpotifyService,
|
|
) {}
|
|
|
|
findAll(
|
|
relations: Record<string, boolean> = { tracks: true },
|
|
where?: Partial<PlaylistEntity>,
|
|
): Promise<PlaylistEntity[]> {
|
|
return this.repository.find({ where, relations });
|
|
}
|
|
|
|
findOne(id: number): Promise<PlaylistEntity | null> {
|
|
return this.repository.findOneBy({ id });
|
|
}
|
|
|
|
async remove(id: number): Promise<void> {
|
|
await this.repository.delete(id);
|
|
this.io.emit(WsPlaylistOperation.Delete, { id });
|
|
}
|
|
|
|
|
|
async createFromSearch(
|
|
artist: string,
|
|
title: string,
|
|
): Promise<{ spotifyUrl: string; name: string; artist: string }> {
|
|
const track = await this.spotifyService.searchTrack(artist, title);
|
|
|
|
await this.create({
|
|
spotifyUrl: track.spotifyUrl,
|
|
name: track.name,
|
|
active: false,
|
|
isTrack: true,
|
|
} as PlaylistEntity);
|
|
|
|
return {
|
|
spotifyUrl: track.spotifyUrl,
|
|
name: track.name,
|
|
artist: track.artist,
|
|
};
|
|
}
|
|
|
|
async create(playlist: PlaylistEntity): Promise<void> {
|
|
// Detect if URL is for a single track or a playlist and route accordingly
|
|
const isTrack = this.spotifyService.isTrackUrl(playlist.spotifyUrl);
|
|
const isArtist = this.spotifyService.isArtistUrl(playlist.spotifyUrl);
|
|
|
|
if (isTrack) {
|
|
await this.createSingleTrack(playlist);
|
|
} else if (isArtist) {
|
|
await this.createArtistLibrary(playlist);
|
|
} else {
|
|
await this.createPlaylist(playlist);
|
|
}
|
|
}
|
|
|
|
private async createSingleTrack(playlist: PlaylistEntity): Promise<void> {
|
|
let trackDetail: { name: string; artist: string; image: string; durationMs?: number };
|
|
let playlist2Save: PlaylistEntity;
|
|
try {
|
|
trackDetail = await this.spotifyService.getTrackDetail(
|
|
playlist.spotifyUrl,
|
|
);
|
|
this.logger.debug(`Track detail retrieved: ${trackDetail.name}`);
|
|
|
|
playlist2Save = {
|
|
...playlist,
|
|
name: trackDetail.name,
|
|
coverUrl: trackDetail.image,
|
|
isTrack: true,
|
|
active: false, // Single tracks cannot be subscribed
|
|
};
|
|
// Don't create folder structure for individual tracks - they go in root
|
|
} catch (err) {
|
|
this.logger.error(`Error getting track details: ${err}`);
|
|
playlist2Save = {
|
|
...playlist,
|
|
error: toSafeErrorMessage(err),
|
|
isTrack: true,
|
|
};
|
|
}
|
|
const savedPlaylist = await this.save(playlist2Save);
|
|
|
|
if (trackDetail) {
|
|
try {
|
|
await this.trackService.create(
|
|
{
|
|
artist: trackDetail.artist,
|
|
name: trackDetail.name,
|
|
spotifyUrl: playlist.spotifyUrl,
|
|
coverUrl: trackDetail.image,
|
|
durationMs: trackDetail.durationMs,
|
|
},
|
|
savedPlaylist,
|
|
);
|
|
} catch (error) {
|
|
this.logger.error(
|
|
`Error creating track "${trackDetail.artist} - ${trackDetail.name}": ${error.message}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
private async createArtistLibrary(playlist: PlaylistEntity): Promise<void> {
|
|
let detail: { tracks: any[]; name: string; image: string };
|
|
let playlist2Save: PlaylistEntity;
|
|
|
|
try {
|
|
detail = await this.spotifyService.getArtistLibrary(playlist.spotifyUrl);
|
|
this.logger.debug(
|
|
`Artist library retrieved with ${detail.tracks?.length || 0} tracks`,
|
|
);
|
|
|
|
playlist2Save = {
|
|
...playlist,
|
|
name: detail.name,
|
|
coverUrl: detail.image,
|
|
active: false,
|
|
};
|
|
this.createPlaylistFolderStructure(playlist2Save.name);
|
|
} catch (err) {
|
|
this.logger.error(`Error getting artist library details: ${err}`);
|
|
playlist2Save = {
|
|
...playlist,
|
|
name: 'Failed artist library import',
|
|
error: toSafeErrorMessage(err),
|
|
active: false,
|
|
};
|
|
}
|
|
|
|
const savedPlaylist = await this.save(playlist2Save);
|
|
|
|
if (detail?.tracks && detail.tracks.length > 0) {
|
|
await this.createTracksForSavedPlaylist(detail.tracks, savedPlaylist);
|
|
} else {
|
|
this.logger.warn(`No tracks found for artist library ${savedPlaylist.name}`);
|
|
}
|
|
}
|
|
|
|
private async createTracksForSavedPlaylist(
|
|
tracks: any[],
|
|
savedPlaylist: PlaylistEntity,
|
|
): Promise<void> {
|
|
this.logger.debug(
|
|
`Starting to process ${tracks.length} tracks for ${savedPlaylist.name}`,
|
|
);
|
|
|
|
let processedCount = 0;
|
|
let skippedCount = 0;
|
|
let errorCount = 0;
|
|
|
|
for (const track of 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,
|
|
coverUrl: track.coverUrl || savedPlaylist.coverUrl,
|
|
durationMs: track.durationMs,
|
|
},
|
|
savedPlaylist,
|
|
);
|
|
|
|
processedCount++;
|
|
|
|
if (processedCount % 100 === 0) {
|
|
this.logger.debug(
|
|
`Processed ${processedCount} tracks so far for ${savedPlaylist.name}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
this.logger.error(
|
|
`Error creating track "${
|
|
track?.artist || 'Unknown'
|
|
} - ${track?.name || 'Unknown'}": ${error.message}`,
|
|
);
|
|
errorCount++;
|
|
}
|
|
}
|
|
|
|
this.logger.debug(
|
|
`Finished processing ${savedPlaylist.name}: ` +
|
|
`${processedCount} tracks processed, ${skippedCount} skipped, ${errorCount} errors`,
|
|
);
|
|
}
|
|
|
|
private async createPlaylist(playlist: PlaylistEntity): Promise<void> {
|
|
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,
|
|
coverUrl: detail.image,
|
|
};
|
|
this.createPlaylistFolderStructure(playlist2Save.name);
|
|
} catch (err) {
|
|
this.logger.error(`Error getting playlist details: ${err}`);
|
|
playlist2Save = {
|
|
...playlist,
|
|
error: toSafeErrorMessage(err),
|
|
};
|
|
}
|
|
const savedPlaylist = await this.save(playlist2Save);
|
|
|
|
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,
|
|
coverUrl: track.coverUrl || savedPlaylist.coverUrl, // Use track's album art, fallback to playlist cover
|
|
durationMs: track.durationMs,
|
|
},
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
async save(playlist: PlaylistEntity): Promise<PlaylistEntity> {
|
|
const savedPlaylist = await this.repository.save(playlist);
|
|
this.io.emit(WsPlaylistOperation.New, savedPlaylist);
|
|
return savedPlaylist;
|
|
}
|
|
|
|
async update(id: number, playlist: Partial<PlaylistEntity>): Promise<void> {
|
|
await this.repository.update(id, playlist);
|
|
const dbPlaylist = await this.findOne(id);
|
|
this.io.emit(WsPlaylistOperation.Update, dbPlaylist);
|
|
}
|
|
|
|
async retryFailedOfPlaylist(id: number): Promise<void> {
|
|
const tracks = await this.trackService.getAllByPlaylist(id);
|
|
for (const track of tracks) {
|
|
if (track.status === TrackStatusEnum.Error) {
|
|
await this.trackService.retry(track.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
private createPlaylistFolderStructure(playlistName: string): void {
|
|
const playlistPath = this.utilsService.getPlaylistFolderPath(playlistName);
|
|
!fs.existsSync(playlistPath) && fs.mkdirSync(playlistPath, { recursive: true });
|
|
}
|
|
|
|
@Interval(3_600_000)
|
|
async checkActivePlaylists(): Promise<void> {
|
|
// Only check actual playlists (not individual tracks) that are subscribed
|
|
const activePlaylists = await this.findAll(
|
|
{ active: true, isTrack: false },
|
|
);
|
|
for (const playlist of activePlaylists) {
|
|
let tracks = [];
|
|
try {
|
|
tracks = await this.spotifyService.getPlaylistTracks(
|
|
playlist.spotifyUrl,
|
|
);
|
|
this.createPlaylistFolderStructure(playlist.name);
|
|
} catch (err) {
|
|
await this.update(playlist.id, {
|
|
...playlist,
|
|
error: toSafeErrorMessage(err),
|
|
});
|
|
}
|
|
for (const track of tracks ?? []) {
|
|
const track2Save = {
|
|
artist: track.artist,
|
|
name: track.name,
|
|
spotifyUrl: track.previewUrl,
|
|
};
|
|
const isExist = !!(
|
|
await this.trackService.getAll({
|
|
...track2Save,
|
|
playlist: { id: playlist.id },
|
|
})
|
|
).length;
|
|
if (!isExist) {
|
|
await this.trackService.create(track2Save, playlist);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|