Files
jarri-spooty/src/backend/src/playlist/playlist.service.ts
T

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);
}
}
}
}
}