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 {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;
+42 -37
View File
@@ -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<TrackEntity[]> {
return this.service.getAllByPlaylist(playlistId);
}
@Get()
getAll(): Promise<TrackModel[]> {
return this.service.findAll();
}
@Get('download/:id')
async getFile(
@Res({ passthrough: true }) res: Response,
@Param('id') id: number,
): Promise<StreamableFile> {
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<TrackModel[]> {
return this.service.getAllByPlaylist(playlistId);
}
@Delete(':id')
remove(@Param('id') id: number): Promise<void> {
return this.service.remove(id);
}
@Get('download/:id')
async getFile(@Res({ passthrough: true }) res: Response, @Param('id') id: number): Promise<StreamableFile> {
const track = await this.service.findOne(id);
const fileName = `${track.artist} - ${track.song}.${this.configService.get<string>(EnviromentEnum.FORMAT)}`;
const filePath = createReadStream(resolve(__dirname, '..', this.configService.get<string>(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<void> {
return this.service.remove(id);
}
@Get('retry/:id')
retry(@Param('id') id: number): Promise<void> {
return this.service.retry(id);
}
}
@Get('retry/:id')
retry(@Param('id') id: number): Promise<void> {
return this.service.retry(id);
}
}
+32 -23
View File
@@ -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;
}
@ManyToOne(() => PlaylistEntity, (playlist) => playlist.tracks, {
onDelete: 'CASCADE',
})
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,
}
+9 -12
View File
@@ -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 {}
export class TrackModule {}
+153 -105
View File
@@ -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<TrackEntity>,
private readonly configService: ConfigService,
) {}
constructor(
@InjectRepository(TrackEntity)
private repository: Repository<TrackEntity>,
private readonly configService: ConfigService,
) {}
findAll(
where?: { [key: string]: any },
relations: Record<string, boolean> = {},
): Promise<TrackEntity[]> {
return this.repository.find({ where, relations });
}
findAll(where?: {[key: string]: any}, relations: Record<string, boolean> = {}): Promise<TrackEntity[]> {
return this.repository.find({where, relations});
getAllByPlaylist(id: number): Promise<TrackEntity[]> {
return this.repository.find({ where: { playlist: { id } } });
}
findOne(id: number): Promise<TrackEntity | null> {
return this.repository.findOne({ where: { id }, relations: ['playlist'] });
}
async remove(id: number): Promise<void> {
await this.repository.delete(id);
this.io.emit(WsTrackOperation.Delete, { id });
}
async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> {
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<void> {
await this.repository.update(id, track);
this.io.emit(WsTrackOperation.Update, track);
}
async retry(id: number): Promise<void> {
const track = await this.findOne(id);
await this.update(id, { ...track, status: TrackStatusEnum.New });
}
@Interval(1_000)
async findOnYoutube(): Promise<void> {
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<TrackEntity[]> {
return this.repository.find({where: {playlist: {id}}});
@Interval(1_000)
async downloadFromYoutube(): Promise<void> {
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<TrackEntity | null> {
return this.repository.findOne({where: {id}, relations: ['playlist']});
}
private downloadAndFormat(
track: TrackEntity,
playlist: PlaylistEntity,
): Promise<void> {
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<string>(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<void> {
await this.repository.delete(id);
this.io.emit('trackDelete', {id});
}
getTrackFileName(track: TrackEntity): string {
return `${track.artist} - ${track.name.replace('/', '')}.${this.configService.get<string>(EnviromentEnum.FORMAT)}`;
}
async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> {
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<string>(EnviromentEnum.DOWNLOADS_PATH),
playlist.name,
this.getTrackFileName(track),
);
}
async update(id: number, track: TrackEntity): Promise<void> {
await this.repository.update(id, track);
this.io.emit('trackUpdate', track);
private async changeTracksStatuses(
tracks: TrackEntity[],
status: TrackStatusEnum,
): Promise<void> {
for (const track of tracks) {
await this.update(track.id, { ...track, status });
}
async retry(id: number): Promise<void> {
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<void> {
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<string>(EnviromentEnum.FORMAT))
.on('error', (err) => reject(err))
.pipe(
fs.createWriteStream(
resolve(
__dirname, '..', this.configService.get<string>(EnviromentEnum.DOWNLOADS_PATH), playlistName,
`${track.artist} - ${track.song.replace('/', '')}.${this.configService.get<string>(EnviromentEnum.FORMAT)}`
)
).on('finish', () => res()).on('error', (err) => reject(err))
);
});
}
}
}
}
@@ -1,6 +1,6 @@
<a class="panel-block is-flex is-justify-content-space-between" *ngFor="let track of tracks$ | async">
<div>
<span>{{ track.artist }} - {{ track.song }}</span>&nbsp;
<span>{{ track.artist }} - {{ track.name }}</span>&nbsp;
<a [href]="track.spotifyUrl"
target="_blank"
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 {
id: number;
artist: string;
song: string;
name: string;
spotifyUrl: string;
youtubeUrl: string;
status: TrackStatusEnum;