diff --git a/src/backend/src/playlist/playlist.service.ts b/src/backend/src/playlist/playlist.service.ts index 12353f2..2c9f199 100644 --- a/src/backend/src/playlist/playlist.service.ts +++ b/src/backend/src/playlist/playlist.service.ts @@ -5,12 +5,12 @@ import {PlaylistEntity} from "./playlist.entity"; import {TrackService} from "../track/track.service"; import {WebSocketGateway, WebSocketServer} from "@nestjs/websockets"; import {Server} from "socket.io"; -import {TrackStatusEnum} from "../track/track.model"; import {resolve} from "path"; import {ConfigService} from "@nestjs/config"; import {EnviromentEnum} from "../enviroment.enum"; import * as fs from 'fs'; import {Interval} from "@nestjs/schedule"; +import {TrackStatusEnum} from "../track/track.entity"; const fetch = require('isomorphic-unfetch'); const { getData, getPreview, getTracks, getDetails } = require('spotify-url-info')(fetch); @@ -54,7 +54,7 @@ export class PlaylistService { for(let track of details?.tracks ?? []) { await this.trackService.create({ artist: track.artist, - song: track.name, + name: track.name, spotifyUrl: track.previewUrl, }, savedPlaylist); } @@ -100,7 +100,7 @@ export class PlaylistService { for(let track of details?.tracks ?? []) { const track2Save = { artist: track.artist, - song: track.name, + name: track.name, spotifyUrl: track.previewUrl, } const isExist = !!(await this.trackService.findAll({...track2Save, playlist: {id: playlist.id}})).length; diff --git a/src/backend/src/track/track.controller.ts b/src/backend/src/track/track.controller.ts index 20942f5..206ff89 100644 --- a/src/backend/src/track/track.controller.ts +++ b/src/backend/src/track/track.controller.ts @@ -1,45 +1,50 @@ -import {Controller, Delete, Get, Param, Res, StreamableFile} from '@nestjs/common'; -import {TrackService} from "./track.service"; -import {TrackModel} from "./track.model"; -import {createReadStream} from "fs"; -import { resolve } from 'path'; +import { + Controller, + Delete, + Get, + Param, + Res, + StreamableFile, +} from '@nestjs/common'; +import { TrackService } from './track.service'; +import { createReadStream } from 'fs'; import type { Response } from 'express'; -import {ConfigService} from "@nestjs/config"; -import {EnviromentEnum} from "../enviroment.enum"; +import { ConfigService } from '@nestjs/config'; +import { TrackEntity } from './track.entity'; @Controller('track') export class TrackController { + constructor( + private readonly service: TrackService, + private readonly configService: ConfigService, + ) {} - constructor(private readonly service: TrackService, - private readonly configService: ConfigService) { - } + @Get('playlist/:id') + getAllByPlaylist(@Param('id') playlistId: number): Promise { + return this.service.getAllByPlaylist(playlistId); + } - @Get() - getAll(): Promise { - return this.service.findAll(); - } + @Get('download/:id') + async getFile( + @Res({ passthrough: true }) res: Response, + @Param('id') id: number, + ): Promise { + const track = await this.service.findOne(id); + const fileName = this.service.getTrackFileName(track); + const readStream = createReadStream( + this.service.getFolderName(track, track.playlist), + ); + res.set({ 'Content-Disposition': `attachment; filename="${fileName}` }); + return new StreamableFile(readStream); + } - @Get('playlist/:id') - getAllByPlaylist(@Param('id') playlistId: number): Promise { - return this.service.getAllByPlaylist(playlistId); - } + @Delete(':id') + remove(@Param('id') id: number): Promise { + return this.service.remove(id); + } - @Get('download/:id') - async getFile(@Res({ passthrough: true }) res: Response, @Param('id') id: number): Promise { - const track = await this.service.findOne(id); - const fileName = `${track.artist} - ${track.song}.${this.configService.get(EnviromentEnum.FORMAT)}`; - const filePath = createReadStream(resolve(__dirname, '..', this.configService.get(EnviromentEnum.DOWNLOADS_PATH), track.playlist.name, fileName)); - res.set({'Content-Disposition': `attachment; filename="${fileName}`}); - return new StreamableFile(filePath); - } - - @Delete(':id') - remove(@Param('id') id: number): Promise { - return this.service.remove(id); - } - - @Get('retry/:id') - retry(@Param('id') id: number): Promise { - return this.service.retry(id); - } -} \ No newline at end of file + @Get('retry/:id') + retry(@Param('id') id: number): Promise { + return this.service.retry(id); + } +} diff --git a/src/backend/src/track/track.entity.ts b/src/backend/src/track/track.entity.ts index 7664c60..a997334 100644 --- a/src/backend/src/track/track.entity.ts +++ b/src/backend/src/track/track.entity.ts @@ -1,34 +1,43 @@ -import {Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn, UpdateDateColumn} from 'typeorm'; -import {PlaylistEntity} from "../playlist/playlist.entity"; -import {TrackStatusEnum} from "./track.model"; +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm'; +import { PlaylistEntity } from '../playlist/playlist.entity'; + +export enum TrackStatusEnum { + New, + Searching, + Queued, + Downloading, + Completed, + Error, +} @Entity() export class TrackEntity { + @PrimaryGeneratedColumn() + id?: number; - @PrimaryGeneratedColumn() - id?: number; + @Column() + artist: string; - @Column() - artist: string; + @Column() + name: string; - @Column() - song: string; + @Column() + spotifyUrl: string; - @Column() - spotifyUrl: string; + @Column({ nullable: true }) + youtubeUrl?: string; - @Column({ nullable: true }) - youtubeUrl?: string; + @Column({ default: TrackStatusEnum.New }) + status?: TrackStatusEnum; - @Column({default: TrackStatusEnum.New}) - status?: TrackStatusEnum; + @Column({ nullable: true }) + error?: string; - @Column({ nullable: true }) - error?: string; + @Column({ default: Date.now() }) + createdAt?: number; - @Column({default: Date.now()}) - createdAt?: number; - - @ManyToOne(() => PlaylistEntity, playlist => playlist.tracks, {onDelete: "CASCADE"}) - playlist?: PlaylistEntity; -} \ No newline at end of file + @ManyToOne(() => PlaylistEntity, (playlist) => playlist.tracks, { + onDelete: 'CASCADE', + }) + playlist?: PlaylistEntity; +} diff --git a/src/backend/src/track/track.model.ts b/src/backend/src/track/track.model.ts deleted file mode 100644 index 5caf195..0000000 --- a/src/backend/src/track/track.model.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {PlaylistModel} from "../playlist/playlist.model"; - -export interface TrackModel { - id?: number; - artist: string; - song: string; - spotifyUrl: string; - youtubeUrl?: string; - status?: TrackStatusEnum, - playlist?: PlaylistModel; - createdAt?: number; - error?: string; -} - -export enum TrackStatusEnum { - New, - Searching, - Queued, - Downloading, - Completed, - Error, -} \ No newline at end of file diff --git a/src/backend/src/track/track.module.ts b/src/backend/src/track/track.module.ts index e605964..5c5544a 100644 --- a/src/backend/src/track/track.module.ts +++ b/src/backend/src/track/track.module.ts @@ -1,17 +1,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import {TrackEntity} from "./track.entity"; -import {TrackService} from "./track.service"; -import {TrackController} from "./track.controller"; -import {ConfigModule} from "@nestjs/config"; +import { TrackEntity } from './track.entity'; +import { TrackService } from './track.service'; +import { TrackController } from './track.controller'; +import { ConfigModule } from '@nestjs/config'; @Module({ - imports: [ - TypeOrmModule.forFeature([TrackEntity]), - ConfigModule, - ], - providers: [TrackService], - controllers: [TrackController], - exports: [TrackService], + imports: [TypeOrmModule.forFeature([TrackEntity]), ConfigModule], + providers: [TrackService], + controllers: [TrackController], + exports: [TrackService], }) -export class TrackModule {} \ No newline at end of file +export class TrackModule {} diff --git a/src/backend/src/track/track.service.ts b/src/backend/src/track/track.service.ts index 7b24fb1..103f8e3 100644 --- a/src/backend/src/track/track.service.ts +++ b/src/backend/src/track/track.service.ts @@ -1,124 +1,172 @@ -import {Injectable} from '@nestjs/common'; -import {InjectRepository} from '@nestjs/typeorm'; -import {Repository} from 'typeorm'; -import {TrackEntity} from "./track.entity"; -import {PlaylistEntity} from "../playlist/playlist.entity"; -import {Interval} from "@nestjs/schedule"; -import {TrackStatusEnum} from "./track.model"; +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { TrackEntity, TrackStatusEnum } from './track.entity'; +import { PlaylistEntity } from '../playlist/playlist.entity'; +import { Interval } from '@nestjs/schedule'; import * as yts from 'yt-search'; -import {SearchResult} from 'yt-search'; import * as ytdl from '@distube/ytdl-core'; import * as fs from 'fs'; -import {ConfigService} from "@nestjs/config"; -import {resolve} from "path"; -import {WebSocketGateway, WebSocketServer} from "@nestjs/websockets"; -import {Server} from "socket.io"; +import { ConfigService } from '@nestjs/config'; +import { resolve } from 'path'; +import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; +import { Server } from 'socket.io'; import * as ffmpeg from 'fluent-ffmpeg'; -import {EnviromentEnum} from "../enviroment.enum"; +import { EnviromentEnum } from '../enviroment.enum'; + +enum WsTrackOperation { + New = 'trackNew', + Update = 'trackUpdate', + Delete = 'trackDelete', +} @WebSocketGateway() @Injectable() export class TrackService { + @WebSocketServer() io: Server; + private readonly logger = new Logger(TrackService.name); - @WebSocketServer() io: Server; + constructor( + @InjectRepository(TrackEntity) + private repository: Repository, + private readonly configService: ConfigService, + ) {} - constructor( - @InjectRepository(TrackEntity) - private repository: Repository, - private readonly configService: ConfigService, - ) {} + findAll( + where?: { [key: string]: any }, + relations: Record = {}, + ): Promise { + return this.repository.find({ where, relations }); + } - findAll(where?: {[key: string]: any}, relations: Record = {}): Promise { - return this.repository.find({where, relations}); + getAllByPlaylist(id: number): Promise { + return this.repository.find({ where: { playlist: { id } } }); + } + + findOne(id: number): Promise { + return this.repository.findOne({ where: { id }, relations: ['playlist'] }); + } + + async remove(id: number): Promise { + await this.repository.delete(id); + this.io.emit(WsTrackOperation.Delete, { id }); + } + + async create(track: TrackEntity, playlist?: PlaylistEntity): Promise { + const savedTrack = await this.repository.save({ ...track, playlist }); + this.io.emit(WsTrackOperation.New, { + track: savedTrack, + playlistId: playlist.id, + }); + } + + async update(id: number, track: TrackEntity): Promise { + await this.repository.update(id, track); + this.io.emit(WsTrackOperation.Update, track); + } + + async retry(id: number): Promise { + const track = await this.findOne(id); + await this.update(id, { ...track, status: TrackStatusEnum.New }); + } + + @Interval(1_000) + async findOnYoutube(): Promise { + const newTracks = await this.findAll({ status: TrackStatusEnum.New }); + await this.changeTracksStatuses(newTracks, TrackStatusEnum.Searching); + for (const track of newTracks) { + let updatedTrack: TrackEntity; + try { + const youtubeResult = await yts(`${track.artist} - ${track.name}`); + updatedTrack = { + ...track, + youtubeUrl: youtubeResult.videos[0].url, + status: TrackStatusEnum.Queued, + }; + } catch (err) { + this.logger.error(err); + updatedTrack = { + ...track, + error: String(err), + status: TrackStatusEnum.Error, + }; + } + await this.update(track.id, updatedTrack); } + } - getAllByPlaylist(id: number): Promise { - return this.repository.find({where: {playlist: {id}}}); + @Interval(1_000) + async downloadFromYoutube(): Promise { + const queuedTracks = await this.findAll( + { status: TrackStatusEnum.Queued }, + { playlist: true }, + ); + await this.changeTracksStatuses(queuedTracks, TrackStatusEnum.Downloading); + for (const track of queuedTracks) { + let error: string; + try { + await this.downloadAndFormat(track, track.playlist); + } catch (err) { + this.logger.error(err); + error = String(err); + } + const updatedTrack = { + ...track, + status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed, + ...(error ? { error } : {}), + }; + await this.update(track.id, updatedTrack); } + } - findOne(id: number): Promise { - return this.repository.findOne({where: {id}, relations: ['playlist']}); - } + private downloadAndFormat( + track: TrackEntity, + playlist: PlaylistEntity, + ): Promise { + const ffmpegOptions = [ + '-metadata', + `title=${track.name}`, + '-metadata', + `artist=${track.artist}`, + ]; + return new Promise((res, reject) => { + const audio = ytdl(track.youtubeUrl, { + quality: 'highestaudio', + filter: 'audioonly', + }).on('error', (err) => reject(err)); + ffmpeg(audio) + .outputOptions(ffmpegOptions) + .format(this.configService.get(EnviromentEnum.FORMAT)) + .on('error', (err) => reject(err)) + .pipe( + fs + .createWriteStream(this.getFolderName(track, playlist)) + .on('finish', () => res()) + .on('error', (err) => reject(err)), + ); + }); + } - async remove(id: number): Promise { - await this.repository.delete(id); - this.io.emit('trackDelete', {id}); - } + getTrackFileName(track: TrackEntity): string { + return `${track.artist} - ${track.name.replace('/', '')}.${this.configService.get(EnviromentEnum.FORMAT)}`; + } - async create(track: TrackEntity, playlist?: PlaylistEntity): Promise { - const savedTrack = await this.repository.save({...track, playlist}); - this.io.emit('trackNew', {track: savedTrack, playlistId: playlist.id}); - } + getFolderName(track: TrackEntity, playlist: PlaylistEntity): string { + return resolve( + __dirname, + '..', + this.configService.get(EnviromentEnum.DOWNLOADS_PATH), + playlist.name, + this.getTrackFileName(track), + ); + } - async update(id: number, track: TrackEntity): Promise { - await this.repository.update(id, track); - this.io.emit('trackUpdate', track); + private async changeTracksStatuses( + tracks: TrackEntity[], + status: TrackStatusEnum, + ): Promise { + for (const track of tracks) { + await this.update(track.id, { ...track, status }); } - - async retry(id: number): Promise { - const track = await this.findOne(id); - await this.update(id, {...track, status: TrackStatusEnum.New}); - } - - @Interval(1000) - async findOnYoutube() { - const newTracks = await this.findAll({status: TrackStatusEnum.New}); - for (let track of newTracks) { - await this.update(track.id, {...track, status: TrackStatusEnum.Searching}); - } - for(let track of newTracks) { - let youtubeResult: SearchResult; - let updatedTrack: TrackEntity; - try { - youtubeResult = await yts(`${track.artist} - ${track.song}`); - updatedTrack = {...track, youtubeUrl: youtubeResult.videos[0].url, status: TrackStatusEnum.Queued}; - } catch (err) { - console.log(err); - updatedTrack = {...track, error: String(err), status: TrackStatusEnum.Error}; - } - await this.update(track.id, updatedTrack); - } - } - - @Interval(1000) - async download() { - const queuedTracks = await this.findAll({status: TrackStatusEnum.Queued}, {playlist: true}); - for (let track of queuedTracks) { - await this.update(track.id, {...track, status: TrackStatusEnum.Downloading}); - } - for(let track of queuedTracks) { - let error: string; - try { - await this.youtubeDownload(track, track.playlist.name); - } catch(err) { - console.log(err); - error = String(err); - } - const updatedTrack = { - ...track, - status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed, - ...(error ? {error} : {}), - }; - await this.update(track.id, updatedTrack); - } - } - - private youtubeDownload(track: TrackEntity, playlistName: string): Promise { - return new Promise((res, reject) => { - const audio = ytdl(track.youtubeUrl, {quality: "highestaudio", filter: "audioonly"}) - .on('error', (err) => reject(err)); - ffmpeg(audio) - .outputOptions('-metadata', `title=${track.song}`, '-metadata', `artist=${track.artist}`) - .format(this.configService.get(EnviromentEnum.FORMAT)) - .on('error', (err) => reject(err)) - .pipe( - fs.createWriteStream( - resolve( - __dirname, '..', this.configService.get(EnviromentEnum.DOWNLOADS_PATH), playlistName, - `${track.artist} - ${track.song.replace('/', '')}.${this.configService.get(EnviromentEnum.FORMAT)}` - ) - ).on('finish', () => res()).on('error', (err) => reject(err)) - ); - }); - } -} \ No newline at end of file + } +} diff --git a/src/frontend/src/app/components/track-list/track-list.component.html b/src/frontend/src/app/components/track-list/track-list.component.html index 3160a5c..579cf2b 100644 --- a/src/frontend/src/app/components/track-list/track-list.component.html +++ b/src/frontend/src/app/components/track-list/track-list.component.html @@ -1,6 +1,6 @@