refactor(track-backend): refactor track backend code

This commit is contained in:
raiper34
2024-09-30 11:59:30 +02:00
parent 8ae1f3291c
commit 0e88627ec7
8 changed files with 241 additions and 204 deletions
+3 -3
View File
@@ -5,12 +5,12 @@ import {PlaylistEntity} from "./playlist.entity";
import {TrackService} from "../track/track.service"; import {TrackService} from "../track/track.service";
import {WebSocketGateway, WebSocketServer} from "@nestjs/websockets"; import {WebSocketGateway, WebSocketServer} from "@nestjs/websockets";
import {Server} from "socket.io"; import {Server} from "socket.io";
import {TrackStatusEnum} from "../track/track.model";
import {resolve} from "path"; import {resolve} from "path";
import {ConfigService} from "@nestjs/config"; import {ConfigService} from "@nestjs/config";
import {EnviromentEnum} from "../enviroment.enum"; import {EnviromentEnum} from "../enviroment.enum";
import * as fs from 'fs'; import * as fs from 'fs';
import {Interval} from "@nestjs/schedule"; import {Interval} from "@nestjs/schedule";
import {TrackStatusEnum} from "../track/track.entity";
const fetch = require('isomorphic-unfetch'); const fetch = require('isomorphic-unfetch');
const { getData, getPreview, getTracks, getDetails } = require('spotify-url-info')(fetch); const { getData, getPreview, getTracks, getDetails } = require('spotify-url-info')(fetch);
@@ -54,7 +54,7 @@ export class PlaylistService {
for(let track of details?.tracks ?? []) { for(let track of details?.tracks ?? []) {
await this.trackService.create({ await this.trackService.create({
artist: track.artist, artist: track.artist,
song: track.name, name: track.name,
spotifyUrl: track.previewUrl, spotifyUrl: track.previewUrl,
}, savedPlaylist); }, savedPlaylist);
} }
@@ -100,7 +100,7 @@ export class PlaylistService {
for(let track of details?.tracks ?? []) { for(let track of details?.tracks ?? []) {
const track2Save = { const track2Save = {
artist: track.artist, artist: track.artist,
song: track.name, name: track.name,
spotifyUrl: track.previewUrl, spotifyUrl: track.previewUrl,
} }
const isExist = !!(await this.trackService.findAll({...track2Save, playlist: {id: playlist.id}})).length; const isExist = !!(await this.trackService.findAll({...track2Save, playlist: {id: playlist.id}})).length;
+27 -22
View File
@@ -1,36 +1,41 @@
import {Controller, Delete, Get, Param, Res, StreamableFile} from '@nestjs/common'; import {
import {TrackService} from "./track.service"; Controller,
import {TrackModel} from "./track.model"; Delete,
import {createReadStream} from "fs"; Get,
import { resolve } from 'path'; Param,
Res,
StreamableFile,
} from '@nestjs/common';
import { TrackService } from './track.service';
import { createReadStream } from 'fs';
import type { Response } from 'express'; import type { Response } from 'express';
import {ConfigService} from "@nestjs/config"; import { ConfigService } from '@nestjs/config';
import {EnviromentEnum} from "../enviroment.enum"; import { TrackEntity } from './track.entity';
@Controller('track') @Controller('track')
export class TrackController { export class TrackController {
constructor(
constructor(private readonly service: TrackService, private readonly service: TrackService,
private readonly configService: ConfigService) { private readonly configService: ConfigService,
} ) {}
@Get()
getAll(): Promise<TrackModel[]> {
return this.service.findAll();
}
@Get('playlist/:id') @Get('playlist/:id')
getAllByPlaylist(@Param('id') playlistId: number): Promise<TrackModel[]> { getAllByPlaylist(@Param('id') playlistId: number): Promise<TrackEntity[]> {
return this.service.getAllByPlaylist(playlistId); return this.service.getAllByPlaylist(playlistId);
} }
@Get('download/:id') @Get('download/:id')
async getFile(@Res({ passthrough: true }) res: Response, @Param('id') id: number): Promise<StreamableFile> { async getFile(
@Res({ passthrough: true }) res: Response,
@Param('id') id: number,
): Promise<StreamableFile> {
const track = await this.service.findOne(id); const track = await this.service.findOne(id);
const fileName = `${track.artist} - ${track.song}.${this.configService.get<string>(EnviromentEnum.FORMAT)}`; const fileName = this.service.getTrackFileName(track);
const filePath = createReadStream(resolve(__dirname, '..', this.configService.get<string>(EnviromentEnum.DOWNLOADS_PATH), track.playlist.name, fileName)); const readStream = createReadStream(
res.set({'Content-Disposition': `attachment; filename="${fileName}`}); this.service.getFolderName(track, track.playlist),
return new StreamableFile(filePath); );
res.set({ 'Content-Disposition': `attachment; filename="${fileName}` });
return new StreamableFile(readStream);
} }
@Delete(':id') @Delete(':id')
+17 -8
View File
@@ -1,10 +1,17 @@
import {Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn, UpdateDateColumn} from 'typeorm'; import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm';
import {PlaylistEntity} from "../playlist/playlist.entity"; import { PlaylistEntity } from '../playlist/playlist.entity';
import {TrackStatusEnum} from "./track.model";
export enum TrackStatusEnum {
New,
Searching,
Queued,
Downloading,
Completed,
Error,
}
@Entity() @Entity()
export class TrackEntity { export class TrackEntity {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id?: number; id?: number;
@@ -12,7 +19,7 @@ export class TrackEntity {
artist: string; artist: string;
@Column() @Column()
song: string; name: string;
@Column() @Column()
spotifyUrl: string; spotifyUrl: string;
@@ -20,15 +27,17 @@ export class TrackEntity {
@Column({ nullable: true }) @Column({ nullable: true })
youtubeUrl?: string; youtubeUrl?: string;
@Column({default: TrackStatusEnum.New}) @Column({ default: TrackStatusEnum.New })
status?: TrackStatusEnum; status?: TrackStatusEnum;
@Column({ nullable: true }) @Column({ nullable: true })
error?: string; error?: string;
@Column({default: Date.now()}) @Column({ default: Date.now() })
createdAt?: number; createdAt?: number;
@ManyToOne(() => PlaylistEntity, playlist => playlist.tracks, {onDelete: "CASCADE"}) @ManyToOne(() => PlaylistEntity, (playlist) => playlist.tracks, {
onDelete: 'CASCADE',
})
playlist?: PlaylistEntity; playlist?: PlaylistEntity;
} }
-22
View File
@@ -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,
}
+5 -8
View File
@@ -1,15 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import {TrackEntity} from "./track.entity"; import { TrackEntity } from './track.entity';
import {TrackService} from "./track.service"; import { TrackService } from './track.service';
import {TrackController} from "./track.controller"; import { TrackController } from './track.controller';
import {ConfigModule} from "@nestjs/config"; import { ConfigModule } from '@nestjs/config';
@Module({ @Module({
imports: [ imports: [TypeOrmModule.forFeature([TrackEntity]), ConfigModule],
TypeOrmModule.forFeature([TrackEntity]),
ConfigModule,
],
providers: [TrackService], providers: [TrackService],
controllers: [TrackController], controllers: [TrackController],
exports: [TrackService], exports: [TrackService],
+104 -56
View File
@@ -1,26 +1,30 @@
import {Injectable} from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import {InjectRepository} from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import {Repository} from 'typeorm'; import { Repository } from 'typeorm';
import {TrackEntity} from "./track.entity"; import { TrackEntity, TrackStatusEnum } from './track.entity';
import {PlaylistEntity} from "../playlist/playlist.entity"; import { PlaylistEntity } from '../playlist/playlist.entity';
import {Interval} from "@nestjs/schedule"; import { Interval } from '@nestjs/schedule';
import {TrackStatusEnum} from "./track.model";
import * as yts from 'yt-search'; import * as yts from 'yt-search';
import {SearchResult} from 'yt-search';
import * as ytdl from '@distube/ytdl-core'; import * as ytdl from '@distube/ytdl-core';
import * as fs from 'fs'; import * as fs from 'fs';
import {ConfigService} from "@nestjs/config"; import { ConfigService } from '@nestjs/config';
import {resolve} from "path"; import { resolve } from 'path';
import {WebSocketGateway, WebSocketServer} from "@nestjs/websockets"; import { WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
import {Server} from "socket.io"; import { Server } from 'socket.io';
import * as ffmpeg from 'fluent-ffmpeg'; 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() @WebSocketGateway()
@Injectable() @Injectable()
export class TrackService { export class TrackService {
@WebSocketServer() io: Server; @WebSocketServer() io: Server;
private readonly logger = new Logger(TrackService.name);
constructor( constructor(
@InjectRepository(TrackEntity) @InjectRepository(TrackEntity)
@@ -28,97 +32,141 @@ export class TrackService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
) {} ) {}
findAll(where?: {[key: string]: any}, relations: Record<string, boolean> = {}): Promise<TrackEntity[]> { findAll(
return this.repository.find({where, relations}); where?: { [key: string]: any },
relations: Record<string, boolean> = {},
): Promise<TrackEntity[]> {
return this.repository.find({ where, relations });
} }
getAllByPlaylist(id: number): Promise<TrackEntity[]> { getAllByPlaylist(id: number): Promise<TrackEntity[]> {
return this.repository.find({where: {playlist: {id}}}); return this.repository.find({ where: { playlist: { id } } });
} }
findOne(id: number): Promise<TrackEntity | null> { findOne(id: number): Promise<TrackEntity | null> {
return this.repository.findOne({where: {id}, relations: ['playlist']}); return this.repository.findOne({ where: { id }, relations: ['playlist'] });
} }
async remove(id: number): Promise<void> { async remove(id: number): Promise<void> {
await this.repository.delete(id); await this.repository.delete(id);
this.io.emit('trackDelete', {id}); this.io.emit(WsTrackOperation.Delete, { id });
} }
async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> { async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> {
const savedTrack = await this.repository.save({...track, playlist}); const savedTrack = await this.repository.save({ ...track, playlist });
this.io.emit('trackNew', {track: savedTrack, playlistId: playlist.id}); this.io.emit(WsTrackOperation.New, {
track: savedTrack,
playlistId: playlist.id,
});
} }
async update(id: number, track: TrackEntity): Promise<void> { async update(id: number, track: TrackEntity): Promise<void> {
await this.repository.update(id, track); await this.repository.update(id, track);
this.io.emit('trackUpdate', track); this.io.emit(WsTrackOperation.Update, track);
} }
async retry(id: number): Promise<void> { async retry(id: number): Promise<void> {
const track = await this.findOne(id); const track = await this.findOne(id);
await this.update(id, {...track, status: TrackStatusEnum.New}); await this.update(id, { ...track, status: TrackStatusEnum.New });
} }
@Interval(1000) @Interval(1_000)
async findOnYoutube() { async findOnYoutube(): Promise<void> {
const newTracks = await this.findAll({status: TrackStatusEnum.New}); const newTracks = await this.findAll({ status: TrackStatusEnum.New });
for (let track of newTracks) { await this.changeTracksStatuses(newTracks, TrackStatusEnum.Searching);
await this.update(track.id, {...track, status: TrackStatusEnum.Searching}); for (const track of newTracks) {
}
for(let track of newTracks) {
let youtubeResult: SearchResult;
let updatedTrack: TrackEntity; let updatedTrack: TrackEntity;
try { try {
youtubeResult = await yts(`${track.artist} - ${track.song}`); const youtubeResult = await yts(`${track.artist} - ${track.name}`);
updatedTrack = {...track, youtubeUrl: youtubeResult.videos[0].url, status: TrackStatusEnum.Queued}; updatedTrack = {
...track,
youtubeUrl: youtubeResult.videos[0].url,
status: TrackStatusEnum.Queued,
};
} catch (err) { } catch (err) {
console.log(err); this.logger.error(err);
updatedTrack = {...track, error: String(err), status: TrackStatusEnum.Error}; updatedTrack = {
...track,
error: String(err),
status: TrackStatusEnum.Error,
};
} }
await this.update(track.id, updatedTrack); await this.update(track.id, updatedTrack);
} }
} }
@Interval(1000) @Interval(1_000)
async download() { async downloadFromYoutube(): Promise<void> {
const queuedTracks = await this.findAll({status: TrackStatusEnum.Queued}, {playlist: true}); const queuedTracks = await this.findAll(
for (let track of queuedTracks) { { status: TrackStatusEnum.Queued },
await this.update(track.id, {...track, status: TrackStatusEnum.Downloading}); { playlist: true },
} );
for(let track of queuedTracks) { await this.changeTracksStatuses(queuedTracks, TrackStatusEnum.Downloading);
for (const track of queuedTracks) {
let error: string; let error: string;
try { try {
await this.youtubeDownload(track, track.playlist.name); await this.downloadAndFormat(track, track.playlist);
} catch(err) { } catch (err) {
console.log(err); this.logger.error(err);
error = String(err); error = String(err);
} }
const updatedTrack = { const updatedTrack = {
...track, ...track,
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed, status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
...(error ? {error} : {}), ...(error ? { error } : {}),
}; };
await this.update(track.id, updatedTrack); await this.update(track.id, updatedTrack);
} }
} }
private youtubeDownload(track: TrackEntity, playlistName: string): Promise<void> { private downloadAndFormat(
track: TrackEntity,
playlist: PlaylistEntity,
): Promise<void> {
const ffmpegOptions = [
'-metadata',
`title=${track.name}`,
'-metadata',
`artist=${track.artist}`,
];
return new Promise((res, reject) => { return new Promise((res, reject) => {
const audio = ytdl(track.youtubeUrl, {quality: "highestaudio", filter: "audioonly"}) const audio = ytdl(track.youtubeUrl, {
.on('error', (err) => reject(err)); quality: 'highestaudio',
filter: 'audioonly',
}).on('error', (err) => reject(err));
ffmpeg(audio) ffmpeg(audio)
.outputOptions('-metadata', `title=${track.song}`, '-metadata', `artist=${track.artist}`) .outputOptions(ffmpegOptions)
.format(this.configService.get<string>(EnviromentEnum.FORMAT)) .format(this.configService.get<string>(EnviromentEnum.FORMAT))
.on('error', (err) => reject(err)) .on('error', (err) => reject(err))
.pipe( .pipe(
fs.createWriteStream( fs
resolve( .createWriteStream(this.getFolderName(track, playlist))
__dirname, '..', this.configService.get<string>(EnviromentEnum.DOWNLOADS_PATH), playlistName, .on('finish', () => res())
`${track.artist} - ${track.song.replace('/', '')}.${this.configService.get<string>(EnviromentEnum.FORMAT)}` .on('error', (err) => reject(err)),
)
).on('finish', () => res()).on('error', (err) => reject(err))
); );
}); });
} }
getTrackFileName(track: TrackEntity): string {
return `${track.artist} - ${track.name.replace('/', '')}.${this.configService.get<string>(EnviromentEnum.FORMAT)}`;
}
getFolderName(track: TrackEntity, playlist: PlaylistEntity): string {
return resolve(
__dirname,
'..',
this.configService.get<string>(EnviromentEnum.DOWNLOADS_PATH),
playlist.name,
this.getTrackFileName(track),
);
}
private async changeTracksStatuses(
tracks: TrackEntity[],
status: TrackStatusEnum,
): Promise<void> {
for (const track of tracks) {
await this.update(track.id, { ...track, status });
}
}
} }
@@ -1,6 +1,6 @@
<a class="panel-block is-flex is-justify-content-space-between" *ngFor="let track of tracks$ | async"> <a class="panel-block is-flex is-justify-content-space-between" *ngFor="let track of tracks$ | async">
<div> <div>
<span>{{ track.artist }} - {{ track.song }}</span>&nbsp; <span>{{ track.artist }} - {{ track.name }}</span>&nbsp;
<a [href]="track.spotifyUrl" <a [href]="track.spotifyUrl"
target="_blank" target="_blank"
class="is-color-primary margin-left" title="Spotify preview of track that will be downloaded"> class="is-color-primary margin-left" title="Spotify preview of track that will be downloaded">
@@ -12,7 +12,7 @@ const ENDPOINT = '/api/track';
export interface Track { export interface Track {
id: number; id: number;
artist: string; artist: string;
song: string; name: string;
spotifyUrl: string; spotifyUrl: string;
youtubeUrl: string; youtubeUrl: string;
status: TrackStatusEnum; status: TrackStatusEnum;