refactor(backend): queue system presented
queue system is presented to be more scalable app and reliable download system BREAKING CHANGE: redis is added and required now close #5
This commit is contained in:
+2
-1
@@ -10,10 +10,11 @@ COPY --from=builder /spooty/dist .
|
||||
COPY --from=builder /spooty/src ./src
|
||||
COPY --from=builder /spooty/package.json ./package.json
|
||||
COPY --from=builder /spooty/package-lock.json ./package-lock.json
|
||||
COPY --from=builder /spooty/src/backend/.env.default ./.env
|
||||
COPY --from=builder /spooty/src/backend/.env.docker ./.env
|
||||
RUN rm -rf node_modules
|
||||
RUN npm ci --omit=dev
|
||||
RUN rm -rf src package.json package-lock.json
|
||||
RUN apk add --no-cache ffmpeg
|
||||
RUN apk add --no-cache redis
|
||||
EXPOSE 3000
|
||||
CMD ["node", "backend/main.js"]
|
||||
@@ -1,5 +1,6 @@
|
||||
- Getting started
|
||||
- [Using Docker](docker.md)
|
||||
- [Build from source](source.md)
|
||||
- [Environment variables](variables.md)
|
||||
- [Changelog](CHANGELOG.md)
|
||||
- [License](LICENSE.md)
|
||||
+2
-3
@@ -22,6 +22,5 @@ services:
|
||||
```
|
||||
|
||||
### Environment variables
|
||||
Name | Default | Description |
|
||||
--- |---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
FORMAT | `mp3` | Format of downloaded files (currently fully supported only `mp3` but you can try whatever you want from [ffmpeg](https://ffmpeg.org/ffmpeg-formats.html#Muxers)) |
|
||||
|
||||
For detailed configuration, see available [Environment variables](variables.md).
|
||||
+3
-13
@@ -4,22 +4,12 @@ Spooty can be also build from source files on your own.
|
||||
|
||||
### Requirements
|
||||
- Node v18.19.1 (it is recommended to use `nvm` node version manager to install proper version of node)
|
||||
- Redis in memory cache
|
||||
|
||||
### Process
|
||||
- install Node v18.19.1 using `nvm install` and use that node version `nvm use`
|
||||
- from project root install all dependencies using `npm install`
|
||||
- copy `.env.default` as `.env` in `src/backend` folder and modify desired environment properties (see list below)
|
||||
- copy `.env.default` as `.env` in `src/backend` folder and modify desired environment properties (see [Environment variables](variables.md))
|
||||
- build source files `npm run build`
|
||||
- built project will be stored in `dist` folder
|
||||
- start server `npm run start`
|
||||
|
||||
### `.env` environment variables
|
||||
some behaviour and settings of Spooty can be configured using environment variables and `.env` file.
|
||||
|
||||
Name | Default | Description |
|
||||
--- |--------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
DB_PATH | `./config/db.sqlite` (relative to backend) | Path where Spooty database will be stored |
|
||||
FE_PATH | `../frontend/browser` (relative to backend) | Path to frontend part of application |
|
||||
DOWNLOADS_PATH | `./downloads` (relative to backend) | Path where downaloded files will be stored |
|
||||
FORMAT | `mp3` | Format of downloaded files (currently fully supported only `mp3` but you can try whatever you want from [ffmpeg](https://ffmpeg.org/ffmpeg-formats.html#Muxers)) |
|
||||
PORT | 3000 | Port of Spooty server |
|
||||
- start server `npm run start`
|
||||
@@ -0,0 +1,14 @@
|
||||
# Environment variables
|
||||
|
||||
some behaviour and settings of Spooty can be configured using environment variables and `.env` file.
|
||||
|
||||
Name | Default | Description |
|
||||
--- |---------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
DB_PATH | `./config/db.sqlite` (relative to backend) | Path where Spooty database will be stored |
|
||||
FE_PATH | `../frontend/browser` (relative to backend) | Path to frontend part of application |
|
||||
DOWNLOADS_PATH | `./downloads` (relative to backend) | Path where downaloded files will be stored |
|
||||
FORMAT | `mp3` | Format of downloaded files (currently fully supported only `mp3` but you can try whatever you want from [ffmpeg](https://ffmpeg.org/ffmpeg-formats.html#Muxers)) |
|
||||
PORT | 3000 | Port of Spooty server |
|
||||
REDIS_PORT | 6379 | Port of Redis server |
|
||||
REDIS_HOST | localhost | Host of Redis server |
|
||||
RUN_REDIS | true | Whenever Redis server should be started from backend (recommended for Docker environment) |
|
||||
Generated
+10183
-21611
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -12,10 +12,11 @@
|
||||
"build:docs": "copy-files-from-to --config docs/copy-files-from-to.json",
|
||||
"build": "npm run build:be && npm run build:fe",
|
||||
"gen:fe": "npm run gen -w frontend",
|
||||
"gen:be": "npm run gen -w backend",
|
||||
"start": "npm run start:prod -w backend",
|
||||
"docs": "docsify serve ./docs",
|
||||
"deploy:docs": "npm run build:docs && netlify deploy --dir=dist/docs --prod",
|
||||
"release": "npm run build && docker build -t raiper34/spooty:1.0.0 -t raiper34/spooty:latest . && docker push raiper34/spooty:1.0.0 && docker push raiper34/spooty:latest",
|
||||
"release": "rimraf dist && npm run build && docker build -t raiper34/spooty:1.0.0 -t raiper34/spooty:latest . && docker push raiper34/spooty:1.0.0 && docker push raiper34/spooty:latest",
|
||||
"commit": "cz"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -23,7 +24,8 @@
|
||||
"copy-files-from-to": "^3.2.2",
|
||||
"cz-conventional-changelog": "^3.3.0",
|
||||
"docsify-cli": "^4.4.4",
|
||||
"netlify-cli": "^10.5.1"
|
||||
"netlify-cli": "^10.5.1",
|
||||
"rimraf": "^6.0.1"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
|
||||
@@ -2,4 +2,7 @@ DB_PATH=./config/db.sqlite
|
||||
FE_PATH=../frontend/browser
|
||||
DOWNLOADS_PATH=./downloads
|
||||
FORMAT=mp3
|
||||
PORT=3000
|
||||
PORT=3000
|
||||
REDIS_PORT=6379
|
||||
REDIS_HOST=localhost
|
||||
REDIS_RUN=false
|
||||
@@ -0,0 +1,8 @@
|
||||
DB_PATH=./config/db.sqlite
|
||||
FE_PATH=../frontend/browser
|
||||
DOWNLOADS_PATH=./downloads
|
||||
FORMAT=mp3
|
||||
PORT=3000
|
||||
REDIS_PORT=6379
|
||||
REDIS_HOST=localhost
|
||||
REDIS_RUN=true
|
||||
@@ -17,10 +17,12 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"gen": "nest generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@distube/ytdl-core": "^4.14.4",
|
||||
"@distube/ytdl-core": "^4.15.1",
|
||||
"@nestjs/bullmq": "^10.2.3",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.2.2",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
@@ -32,13 +34,14 @@
|
||||
"@nestjs/websockets": "^10.3.10",
|
||||
"@types/fluent-ffmpeg": "^2.1.24",
|
||||
"@types/yt-search": "^2.10.3",
|
||||
"bullmq": "^5.31.2",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"isomorphic-unfetch": "^4.0.2",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"spotify-url-info": "^3.2.15",
|
||||
"spotify-url-info": "^3.2.18",
|
||||
"sqlite3": "^5.1.7",
|
||||
"yt-search": "^2.11.0"
|
||||
"yt-search": "^2.12.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PlaylistModule } from './playlist/playlist.module';
|
||||
import { PlaylistEntity } from './playlist/playlist.entity';
|
||||
import { resolve } from 'path';
|
||||
import { EnvironmentEnum } from './environmentEnum';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -41,6 +42,19 @@ import { EnvironmentEnum } from './environmentEnum';
|
||||
],
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
BullModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
defaultJobOptions: {
|
||||
removeOnComplete: true,
|
||||
},
|
||||
connection: {
|
||||
host: configService.get<string>(EnvironmentEnum.REDIS_HOST),
|
||||
port: configService.get<number>(EnvironmentEnum.REDIS_PORT),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
TrackModule,
|
||||
PlaylistModule,
|
||||
],
|
||||
|
||||
@@ -3,4 +3,7 @@ export enum EnvironmentEnum {
|
||||
FE_PATH = 'FE_PATH',
|
||||
DOWNLOADS_PATH = 'DOWNLOADS_PATH',
|
||||
FORMAT = 'FORMAT',
|
||||
REDIS_PORT = 'REDIS_PORT',
|
||||
REDIS_HOST = 'REDIS_HOST',
|
||||
REDIS_RUN = 'REDIS_RUN',
|
||||
}
|
||||
|
||||
+16
-2
@@ -2,6 +2,8 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import * as fs from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { EnvironmentEnum } from './environmentEnum';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
@@ -10,8 +12,20 @@ async function bootstrap() {
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
if (!process.env.DOWNLOADS_PATH) {
|
||||
if (!process.env[EnvironmentEnum.DOWNLOADS_PATH]) {
|
||||
throw new Error('DOWNLOADS_PATH environment variable is missing');
|
||||
}
|
||||
const folderName = resolve(__dirname, process.env.DOWNLOADS_PATH);
|
||||
const folderName = resolve(
|
||||
__dirname,
|
||||
process.env[EnvironmentEnum.DOWNLOADS_PATH],
|
||||
);
|
||||
!fs.existsSync(folderName) && fs.mkdirSync(folderName);
|
||||
|
||||
try {
|
||||
// not good idea, but I want to keep simple Dockerfile, I know ideally should be in another container and used docker compose
|
||||
Boolean(process.env[EnvironmentEnum.REDIS_RUN]) &&
|
||||
exec(`redis-server --port ${process.env.REDIS_PORT}`);
|
||||
} catch (e) {
|
||||
console.log('Unable to run redis server form app');
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { PlaylistEntity } from './playlist.entity';
|
||||
@@ -9,8 +9,7 @@ import * as fs from 'fs';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { TrackStatusEnum } from '../track/track.entity';
|
||||
import { UtilsService } from '../shared/utils.service';
|
||||
const fetch = require('isomorphic-unfetch');
|
||||
const { getDetails } = require('spotify-url-info')(fetch);
|
||||
import { SpotifyService } from '../shared/spotify.service';
|
||||
|
||||
enum WsPlaylistOperation {
|
||||
New = 'playlistNew',
|
||||
@@ -22,12 +21,14 @@ enum WsPlaylistOperation {
|
||||
@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(
|
||||
@@ -47,17 +48,17 @@ export class PlaylistService {
|
||||
}
|
||||
|
||||
async create(playlist: PlaylistEntity): Promise<void> {
|
||||
let details;
|
||||
let detail;
|
||||
let playlist2Save: PlaylistEntity;
|
||||
try {
|
||||
details = await getDetails(playlist.spotifyUrl);
|
||||
playlist2Save = { ...playlist, name: details.preview.title };
|
||||
detail = await this.spotifyService.getPlaylistDetail(playlist.spotifyUrl);
|
||||
playlist2Save = { ...playlist, name: detail.name };
|
||||
this.createPlaylistFolderStructure(playlist2Save.name);
|
||||
} catch (err) {
|
||||
playlist2Save = { ...playlist, error: String(err) };
|
||||
}
|
||||
const savedPlaylist = await this.save(playlist2Save);
|
||||
this.createPlaylistFolderStructure(savedPlaylist.name);
|
||||
for (const track of details?.tracks ?? []) {
|
||||
for (const track of detail.tracks ?? []) {
|
||||
await this.trackService.create(
|
||||
{
|
||||
artist: track.artist,
|
||||
@@ -99,21 +100,21 @@ export class PlaylistService {
|
||||
async checkActivePlaylists(): Promise<void> {
|
||||
const activePlaylists = await this.findAll({}, { active: true });
|
||||
for (const playlist of activePlaylists) {
|
||||
let details;
|
||||
let tracks = [];
|
||||
try {
|
||||
details = await getDetails(playlist.spotifyUrl);
|
||||
tracks = await this.spotifyService.getPlaylistTracks(playlist.spotifyUrl);
|
||||
this.createPlaylistFolderStructure(playlist.name);
|
||||
} catch (err) {
|
||||
await this.update(playlist.id, { ...playlist, error: String(err) });
|
||||
}
|
||||
this.createPlaylistFolderStructure(playlist.name);
|
||||
for (const track of details?.tracks ?? []) {
|
||||
for (const track of tracks ?? []) {
|
||||
const track2Save = {
|
||||
artist: track.artist,
|
||||
name: track.name,
|
||||
spotifyUrl: track.previewUrl,
|
||||
};
|
||||
const isExist = !!(
|
||||
await this.trackService.findAll({
|
||||
await this.trackService.getAll({
|
||||
...track2Save,
|
||||
playlist: { id: playlist.id },
|
||||
})
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UtilsService } from './utils.service';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SpotifyService } from './spotify.service';
|
||||
import { YoutubeService } from './youtube.service';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [UtilsService],
|
||||
providers: [UtilsService, SpotifyService, YoutubeService],
|
||||
controllers: [],
|
||||
exports: [UtilsService],
|
||||
exports: [UtilsService, SpotifyService, YoutubeService],
|
||||
})
|
||||
export class SharedModule {}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TrackService } from '../track/track.service';
|
||||
const fetch = require('isomorphic-unfetch');
|
||||
const { getDetails } = require('spotify-url-info')(fetch);
|
||||
|
||||
@Injectable()
|
||||
export class SpotifyService {
|
||||
private readonly logger = new Logger(TrackService.name);
|
||||
|
||||
async getPlaylistDetail(
|
||||
spotifyUrl: string,
|
||||
): Promise<{ name: string; tracks: any[] }> {
|
||||
this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`);
|
||||
const detail = await getDetails(spotifyUrl);
|
||||
return { name: detail.preview.title, tracks: detail?.tracks ?? [] };
|
||||
}
|
||||
|
||||
async getPlaylistTracks(spotifyUrl: string): Promise<any[]> {
|
||||
this.logger.debug(`Get playlist ${spotifyUrl} on Spotify`);
|
||||
return (await getDetails(spotifyUrl)?.tracks) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TrackEntity } from '../track/track.entity';
|
||||
import { EnvironmentEnum } from '../environmentEnum';
|
||||
import { TrackService } from '../track/track.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs';
|
||||
import * as ffmpeg from 'fluent-ffmpeg';
|
||||
import * as yts from 'yt-search';
|
||||
import * as ytdl from '@distube/ytdl-core';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
const META_DATA_TITLE = '-metadata';
|
||||
const YT_SETTINGS: ytdl.downloadOptions = {
|
||||
quality: 'highestaudio',
|
||||
filter: 'audioonly',
|
||||
};
|
||||
|
||||
enum StreamStates {
|
||||
Finish = 'finish',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class YoutubeService {
|
||||
private readonly logger = new Logger(TrackService.name);
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async findOnYoutubeOne(artist: string, name: string): Promise<string> {
|
||||
this.logger.debug(`Searching ${artist} - ${name} on YT`);
|
||||
const url = (await yts(`${artist} - ${name}`)).videos[0].url;
|
||||
this.logger.debug(`Found ${artist} - ${name} on ${url}`);
|
||||
return url;
|
||||
}
|
||||
|
||||
downloadAndFormat(track: TrackEntity, folderName: string): Promise<void> {
|
||||
this.logger.debug(`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`);
|
||||
return new Promise((res, reject) => {
|
||||
ffmpeg(this.getYoutubeAudio(track.youtubeUrl, reject))
|
||||
.outputOptions(...this.getFfmpegOptions(track.name, track.artist))
|
||||
.format(this.configService.get<string>(EnvironmentEnum.FORMAT))
|
||||
.on(StreamStates.Error, (err) => reject(err))
|
||||
.pipe(
|
||||
fs
|
||||
.createWriteStream(folderName)
|
||||
.on(StreamStates.Finish, () => {
|
||||
this.logger.debug(
|
||||
`Downloaded ${track.artist} - ${track.name} to ${folderName}`,
|
||||
);
|
||||
res();
|
||||
})
|
||||
.on(StreamStates.Error, (err) => reject(err)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private getFfmpegOptions(name: string, artist: string): string[] {
|
||||
return [
|
||||
META_DATA_TITLE,
|
||||
`title=${name}`,
|
||||
META_DATA_TITLE,
|
||||
`artist=${artist}`,
|
||||
];
|
||||
}
|
||||
|
||||
private getYoutubeAudio(
|
||||
youtubeUrl: string,
|
||||
reject: (reason: any) => void,
|
||||
): Readable {
|
||||
return ytdl(youtubeUrl, YT_SETTINGS).on(StreamStates.Error, (err) =>
|
||||
reject(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { TrackService } from './track.service';
|
||||
import { TrackEntity } from './track.entity';
|
||||
|
||||
@Processor('track-download-processor')
|
||||
export class TrackDownloadProcessor extends WorkerHost {
|
||||
constructor(private readonly trackService: TrackService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<TrackEntity, void, string>): Promise<void> {
|
||||
await this.trackService.downloadFromYoutube(job.data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Processor, WorkerHost } from '@nestjs/bullmq';
|
||||
import { Job } from 'bullmq';
|
||||
import { TrackService } from './track.service';
|
||||
import { TrackEntity } from './track.entity';
|
||||
|
||||
@Processor('track-search-processor')
|
||||
export class TrackSearchProcessor extends WorkerHost {
|
||||
constructor(private readonly trackService: TrackService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<TrackEntity, void, string>): Promise<void> {
|
||||
await this.trackService.findOnYoutube(job.data);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export class TrackController {
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@Param('id') id: number,
|
||||
): Promise<StreamableFile> {
|
||||
const track = await this.service.findOne(id);
|
||||
const track = await this.service.get(id);
|
||||
const fileName = this.service.getTrackFileName(track);
|
||||
const readStream = createReadStream(
|
||||
this.service.getFolderName(track, track.playlist),
|
||||
|
||||
@@ -5,14 +5,25 @@ import { TrackService } from './track.service';
|
||||
import { TrackController } from './track.controller';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import {
|
||||
TrackDownloadProcessor,
|
||||
} from './track-download.processor';
|
||||
import {
|
||||
TrackSearchProcessor,
|
||||
} from './track-search.processor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([TrackEntity]),
|
||||
BullModule.registerQueue(
|
||||
{ name: 'track-search-processor' },
|
||||
{ name: 'track-download-processor' },
|
||||
),
|
||||
ConfigModule,
|
||||
SharedModule,
|
||||
],
|
||||
providers: [TrackService],
|
||||
providers: [TrackService, TrackDownloadProcessor, TrackSearchProcessor],
|
||||
controllers: [TrackController],
|
||||
exports: [TrackService],
|
||||
})
|
||||
|
||||
@@ -3,17 +3,15 @@ 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 * 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 * as ffmpeg from 'fluent-ffmpeg';
|
||||
import { EnvironmentEnum } from '../environmentEnum';
|
||||
import { UtilsService } from '../shared/utils.service';
|
||||
import { Queue } from 'bullmq';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { YoutubeService } from '../shared/youtube.service';
|
||||
|
||||
enum WsTrackOperation {
|
||||
New = 'trackNew',
|
||||
@@ -30,11 +28,14 @@ export class TrackService {
|
||||
constructor(
|
||||
@InjectRepository(TrackEntity)
|
||||
private repository: Repository<TrackEntity>,
|
||||
@InjectQueue('track-download-processor') private trackDownloadQueue: Queue,
|
||||
@InjectQueue('track-search-processor') private trackSearchQueue: Queue,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly utilsService: UtilsService,
|
||||
private readonly youtubeService: YoutubeService,
|
||||
) {}
|
||||
|
||||
findAll(
|
||||
getAll(
|
||||
where?: { [key: string]: any },
|
||||
relations: Record<string, boolean> = {},
|
||||
): Promise<TrackEntity[]> {
|
||||
@@ -45,7 +46,7 @@ export class TrackService {
|
||||
return this.repository.find({ where: { playlist: { id } } });
|
||||
}
|
||||
|
||||
findOne(id: number): Promise<TrackEntity | null> {
|
||||
get(id: number): Promise<TrackEntity | null> {
|
||||
return this.repository.findOne({ where: { id }, relations: ['playlist'] });
|
||||
}
|
||||
|
||||
@@ -56,6 +57,9 @@ export class TrackService {
|
||||
|
||||
async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> {
|
||||
const savedTrack = await this.repository.save({ ...track, playlist });
|
||||
await this.trackSearchQueue.add('', savedTrack, {
|
||||
jobId: `id-${savedTrack.id}`,
|
||||
});
|
||||
this.io.emit(WsTrackOperation.New, {
|
||||
track: savedTrack,
|
||||
playlistId: playlist.id,
|
||||
@@ -68,85 +72,62 @@ export class TrackService {
|
||||
}
|
||||
|
||||
async retry(id: number): Promise<void> {
|
||||
const track = await this.findOne(id);
|
||||
const track = await this.get(id);
|
||||
await this.trackSearchQueue.add('', track, { jobId: `id-${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);
|
||||
async findOnYoutube(track: TrackEntity): Promise<void> {
|
||||
if (!(await this.get(track.id))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
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>(EnvironmentEnum.FORMAT))
|
||||
.on('error', (err) => reject(err))
|
||||
.pipe(
|
||||
fs
|
||||
.createWriteStream(this.getFolderName(track, playlist))
|
||||
.on('finish', () => res())
|
||||
.on('error', (err) => reject(err)),
|
||||
);
|
||||
await this.update(track.id, {
|
||||
...track,
|
||||
status: TrackStatusEnum.Searching,
|
||||
});
|
||||
let updatedTrack: TrackEntity;
|
||||
try {
|
||||
const youtubeUrl = await this.youtubeService.findOnYoutubeOne(
|
||||
track.artist,
|
||||
track.name,
|
||||
);
|
||||
updatedTrack = { ...track, youtubeUrl, status: TrackStatusEnum.Queued };
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
updatedTrack = {
|
||||
...track,
|
||||
error: String(err),
|
||||
status: TrackStatusEnum.Error,
|
||||
};
|
||||
}
|
||||
await this.trackDownloadQueue.add('', updatedTrack, {
|
||||
jobId: `id-${updatedTrack.id}`,
|
||||
});
|
||||
await this.update(track.id, updatedTrack);
|
||||
}
|
||||
|
||||
async downloadFromYoutube(track: TrackEntity): Promise<void> {
|
||||
if (!(await this.get(track.id))) {
|
||||
return;
|
||||
}
|
||||
await this.update(track.id, {
|
||||
...track,
|
||||
status: TrackStatusEnum.Downloading,
|
||||
});
|
||||
let error: string;
|
||||
try {
|
||||
const folderName = this.getFolderName(track, track.playlist);
|
||||
await this.youtubeService.downloadAndFormat(track, folderName);
|
||||
} 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);
|
||||
}
|
||||
|
||||
getTrackFileName(track: TrackEntity): string {
|
||||
@@ -159,13 +140,4 @@ export class TrackService {
|
||||
this.getTrackFileName(track),
|
||||
);
|
||||
}
|
||||
|
||||
private async changeTracksStatuses(
|
||||
tracks: TrackEntity[],
|
||||
status: TrackStatusEnum,
|
||||
): Promise<void> {
|
||||
for (const track of tracks) {
|
||||
await this.update(track.id, { ...track, status });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-13
@@ -11,14 +11,15 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^18.0.0",
|
||||
"@angular/common": "^18.0.0",
|
||||
"@angular/compiler": "^18.0.0",
|
||||
"@angular/core": "^18.0.0",
|
||||
"@angular/forms": "^18.0.0",
|
||||
"@angular/platform-browser": "^18.0.0",
|
||||
"@angular/platform-browser-dynamic": "^18.0.0",
|
||||
"@angular/router": "^18.0.0",
|
||||
"@angular/animations": "^19.0.6",
|
||||
"@angular/common": "^19.0.6",
|
||||
"@angular/compiler": "^19.0.6",
|
||||
"@angular/core": "^19.0.6",
|
||||
"@angular/forms": "^19.0.6",
|
||||
"@angular/platform-browser": "^19.0.6",
|
||||
"@angular/platform-browser-dynamic": "^19.0.6",
|
||||
"@angular/router": "^19.0.6",
|
||||
"@distube/ytdl-core": "^4.15.9",
|
||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||
"@ngneat/elf": "^2.5.1",
|
||||
"@ngneat/elf-cli-ng": "^1.0.0",
|
||||
@@ -32,12 +33,12 @@
|
||||
"ngx-socket-io": "^4.7.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.14.3"
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^18.0.3",
|
||||
"@angular/cli": "^18.0.3",
|
||||
"@angular/compiler-cli": "^18.0.0",
|
||||
"@angular-devkit/build-angular": "^19.0.7",
|
||||
"@angular/cli": "^19.0.7",
|
||||
"@angular/compiler-cli": "^19.0.6",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"jasmine-core": "~5.1.0",
|
||||
"karma": "~6.4.0",
|
||||
@@ -45,6 +46,6 @@
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.4.2"
|
||||
"typescript": "~5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {RouterOutlet} from '@angular/router';
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {CommonModule, NgFor} from "@angular/common";
|
||||
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
||||
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, FormsModule, NgFor, PlaylistBoxComponent],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss'
|
||||
selector: 'app-root',
|
||||
imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
standalone: true,
|
||||
})
|
||||
export class AppComponent {
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</ng-template>
|
||||
<a [href]="playlist.spotifyUrl"
|
||||
target="_blank"
|
||||
class="is-color-black" title="Link to Spotify url to be download from">
|
||||
class="has-text-black" title="Link to Spotify url to be download from">
|
||||
<i class="fa-brands fa-spotify"></i>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -14,17 +14,16 @@ const STATUS2CLASS = {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-box',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
AsyncPipe,
|
||||
NgForOf,
|
||||
NgIf,
|
||||
TrackListComponent
|
||||
],
|
||||
templateUrl: './playlist-box.component.html',
|
||||
styleUrl: './playlist-box.component.scss'
|
||||
selector: 'app-playlist-box',
|
||||
imports: [
|
||||
CommonModule,
|
||||
AsyncPipe,
|
||||
NgIf,
|
||||
TrackListComponent
|
||||
],
|
||||
templateUrl: './playlist-box.component.html',
|
||||
styleUrl: './playlist-box.component.scss',
|
||||
standalone: true
|
||||
})
|
||||
export class PlaylistBoxComponent {
|
||||
|
||||
|
||||
@@ -3,17 +3,17 @@
|
||||
<span>{{ track.artist }} - {{ track.name }}</span>
|
||||
<a [href]="track.spotifyUrl"
|
||||
target="_blank"
|
||||
class="is-color-primary margin-left" title="Spotify preview of track that will be downloaded">
|
||||
class="has-text-primary margin-left" title="Spotify preview of track that will be downloaded">
|
||||
<i class="fa-brands fa-spotify"></i>
|
||||
</a>
|
||||
<a [href]="track.youtubeUrl"
|
||||
target="_blank"
|
||||
class="is-color-danger is-color-black" title="Youtube searched track that will be downloaded">
|
||||
class="has-text-danger is-color-black" title="Youtube searched track that will be downloaded">
|
||||
<i class="fa-brands fa-youtube"></i>
|
||||
</a>
|
||||
<a *ngIf="track.status === trackStatuses.Completed"
|
||||
href="api/track/download/{{track.id}}"
|
||||
class="is-color-info"
|
||||
class="has-text-info"
|
||||
title="Download downloaded and locally saved file"
|
||||
download>
|
||||
<i class="fa-solid fa-download"></i>
|
||||
|
||||
@@ -5,11 +5,11 @@ import {Observable} from "rxjs";
|
||||
import {Track, TrackStatusEnum} from "../../models/track";
|
||||
|
||||
@Component({
|
||||
selector: 'app-track-list',
|
||||
selector: 'app-track-list',
|
||||
imports: [CommonModule, NgFor, NgSwitch, NgSwitchCase],
|
||||
templateUrl: './track-list.component.html',
|
||||
styleUrl: './track-list.component.scss',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgFor, NgSwitch, NgSwitchCase],
|
||||
templateUrl: './track-list.component.html',
|
||||
styleUrl: './track-list.component.scss'
|
||||
})
|
||||
export class TrackListComponent {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user