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:
raiper34
2025-01-26 22:34:04 +01:00
parent 87067ce6ec
commit e5af53483a
28 changed files with 10517 additions and 21782 deletions
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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`
+14
View File
@@ -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) |
+10183 -21611
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -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": {
+4 -1
View File
@@ -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
+8
View File
@@ -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
+7 -4
View File
@@ -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",
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+14 -13
View File
@@ -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 },
})
+4 -2
View File
@@ -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 {}
+22
View File
@@ -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) ?? [];
}
}
+74
View File
@@ -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);
}
}
+1 -1
View File
@@ -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),
+12 -1
View File
@@ -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],
})
+62 -90
View File
@@ -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
View File
@@ -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"
}
}
+5 -6
View File
@@ -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>&nbsp;
<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>&nbsp;
<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>&nbsp;
<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>&nbsp;
<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 {