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/src ./src
|
||||||
COPY --from=builder /spooty/package.json ./package.json
|
COPY --from=builder /spooty/package.json ./package.json
|
||||||
COPY --from=builder /spooty/package-lock.json ./package-lock.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 rm -rf node_modules
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
RUN rm -rf src package.json package-lock.json
|
RUN rm -rf src package.json package-lock.json
|
||||||
RUN apk add --no-cache ffmpeg
|
RUN apk add --no-cache ffmpeg
|
||||||
|
RUN apk add --no-cache redis
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "backend/main.js"]
|
CMD ["node", "backend/main.js"]
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
- Getting started
|
- Getting started
|
||||||
- [Using Docker](docker.md)
|
- [Using Docker](docker.md)
|
||||||
- [Build from source](source.md)
|
- [Build from source](source.md)
|
||||||
|
- [Environment variables](variables.md)
|
||||||
- [Changelog](CHANGELOG.md)
|
- [Changelog](CHANGELOG.md)
|
||||||
- [License](LICENSE.md)
|
- [License](LICENSE.md)
|
||||||
+2
-3
@@ -22,6 +22,5 @@ services:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Environment variables
|
### Environment variables
|
||||||
Name | Default | Description |
|
|
||||||
--- |---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
For detailed configuration, see available [Environment variables](variables.md).
|
||||||
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)) |
|
|
||||||
+3
-13
@@ -4,22 +4,12 @@ Spooty can be also build from source files on your own.
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
- Node v18.19.1 (it is recommended to use `nvm` node version manager to install proper version of node)
|
- Node v18.19.1 (it is recommended to use `nvm` node version manager to install proper version of node)
|
||||||
|
- Redis in memory cache
|
||||||
|
|
||||||
### Process
|
### Process
|
||||||
- install Node v18.19.1 using `nvm install` and use that node version `nvm use`
|
- install Node v18.19.1 using `nvm install` and use that node version `nvm use`
|
||||||
- from project root install all dependencies using `npm install`
|
- 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`
|
- build source files `npm run build`
|
||||||
- built project will be stored in `dist` folder
|
- built project will be stored in `dist` folder
|
||||||
- start server `npm run start`
|
- 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 |
|
|
||||||
@@ -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:docs": "copy-files-from-to --config docs/copy-files-from-to.json",
|
||||||
"build": "npm run build:be && npm run build:fe",
|
"build": "npm run build:be && npm run build:fe",
|
||||||
"gen:fe": "npm run gen -w frontend",
|
"gen:fe": "npm run gen -w frontend",
|
||||||
|
"gen:be": "npm run gen -w backend",
|
||||||
"start": "npm run start:prod -w backend",
|
"start": "npm run start:prod -w backend",
|
||||||
"docs": "docsify serve ./docs",
|
"docs": "docsify serve ./docs",
|
||||||
"deploy:docs": "npm run build:docs && netlify deploy --dir=dist/docs --prod",
|
"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"
|
"commit": "cz"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -23,7 +24,8 @@
|
|||||||
"copy-files-from-to": "^3.2.2",
|
"copy-files-from-to": "^3.2.2",
|
||||||
"cz-conventional-changelog": "^3.3.0",
|
"cz-conventional-changelog": "^3.3.0",
|
||||||
"docsify-cli": "^4.4.4",
|
"docsify-cli": "^4.4.4",
|
||||||
"netlify-cli": "^10.5.1"
|
"netlify-cli": "^10.5.1",
|
||||||
|
"rimraf": "^6.0.1"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
|
|||||||
@@ -2,4 +2,7 @@ DB_PATH=./config/db.sqlite
|
|||||||
FE_PATH=../frontend/browser
|
FE_PATH=../frontend/browser
|
||||||
DOWNLOADS_PATH=./downloads
|
DOWNLOADS_PATH=./downloads
|
||||||
FORMAT=mp3
|
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:watch": "jest --watch",
|
||||||
"test:cov": "jest --coverage",
|
"test:cov": "jest --coverage",
|
||||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"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": {
|
"dependencies": {
|
||||||
"@distube/ytdl-core": "^4.14.4",
|
"@distube/ytdl-core": "^4.15.1",
|
||||||
|
"@nestjs/bullmq": "^10.2.3",
|
||||||
"@nestjs/common": "^10.0.0",
|
"@nestjs/common": "^10.0.0",
|
||||||
"@nestjs/config": "^3.2.2",
|
"@nestjs/config": "^3.2.2",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
@@ -32,13 +34,14 @@
|
|||||||
"@nestjs/websockets": "^10.3.10",
|
"@nestjs/websockets": "^10.3.10",
|
||||||
"@types/fluent-ffmpeg": "^2.1.24",
|
"@types/fluent-ffmpeg": "^2.1.24",
|
||||||
"@types/yt-search": "^2.10.3",
|
"@types/yt-search": "^2.10.3",
|
||||||
|
"bullmq": "^5.31.2",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"isomorphic-unfetch": "^4.0.2",
|
"isomorphic-unfetch": "^4.0.2",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"spotify-url-info": "^3.2.15",
|
"spotify-url-info": "^3.2.18",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7",
|
||||||
"yt-search": "^2.11.0"
|
"yt-search": "^2.12.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PlaylistModule } from './playlist/playlist.module';
|
|||||||
import { PlaylistEntity } from './playlist/playlist.entity';
|
import { PlaylistEntity } from './playlist/playlist.entity';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { EnvironmentEnum } from './environmentEnum';
|
import { EnvironmentEnum } from './environmentEnum';
|
||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -41,6 +42,19 @@ import { EnvironmentEnum } from './environmentEnum';
|
|||||||
],
|
],
|
||||||
inject: [ConfigService],
|
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,
|
TrackModule,
|
||||||
PlaylistModule,
|
PlaylistModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ export enum EnvironmentEnum {
|
|||||||
FE_PATH = 'FE_PATH',
|
FE_PATH = 'FE_PATH',
|
||||||
DOWNLOADS_PATH = 'DOWNLOADS_PATH',
|
DOWNLOADS_PATH = 'DOWNLOADS_PATH',
|
||||||
FORMAT = 'FORMAT',
|
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 { AppModule } from './app.module';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { EnvironmentEnum } from './environmentEnum';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@@ -10,8 +12,20 @@ async function bootstrap() {
|
|||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|
||||||
if (!process.env.DOWNLOADS_PATH) {
|
if (!process.env[EnvironmentEnum.DOWNLOADS_PATH]) {
|
||||||
throw new Error('DOWNLOADS_PATH environment variable is missing');
|
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);
|
!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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { PlaylistEntity } from './playlist.entity';
|
import { PlaylistEntity } from './playlist.entity';
|
||||||
@@ -9,8 +9,7 @@ import * as fs from 'fs';
|
|||||||
import { Interval } from '@nestjs/schedule';
|
import { Interval } from '@nestjs/schedule';
|
||||||
import { TrackStatusEnum } from '../track/track.entity';
|
import { TrackStatusEnum } from '../track/track.entity';
|
||||||
import { UtilsService } from '../shared/utils.service';
|
import { UtilsService } from '../shared/utils.service';
|
||||||
const fetch = require('isomorphic-unfetch');
|
import { SpotifyService } from '../shared/spotify.service';
|
||||||
const { getDetails } = require('spotify-url-info')(fetch);
|
|
||||||
|
|
||||||
enum WsPlaylistOperation {
|
enum WsPlaylistOperation {
|
||||||
New = 'playlistNew',
|
New = 'playlistNew',
|
||||||
@@ -22,12 +21,14 @@ enum WsPlaylistOperation {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlaylistService {
|
export class PlaylistService {
|
||||||
@WebSocketServer() io: Server;
|
@WebSocketServer() io: Server;
|
||||||
|
private readonly logger = new Logger(TrackService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(PlaylistEntity)
|
@InjectRepository(PlaylistEntity)
|
||||||
private repository: Repository<PlaylistEntity>,
|
private repository: Repository<PlaylistEntity>,
|
||||||
private readonly trackService: TrackService,
|
private readonly trackService: TrackService,
|
||||||
private readonly utilsService: UtilsService,
|
private readonly utilsService: UtilsService,
|
||||||
|
private readonly spotifyService: SpotifyService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
findAll(
|
findAll(
|
||||||
@@ -47,17 +48,17 @@ export class PlaylistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(playlist: PlaylistEntity): Promise<void> {
|
async create(playlist: PlaylistEntity): Promise<void> {
|
||||||
let details;
|
let detail;
|
||||||
let playlist2Save: PlaylistEntity;
|
let playlist2Save: PlaylistEntity;
|
||||||
try {
|
try {
|
||||||
details = await getDetails(playlist.spotifyUrl);
|
detail = await this.spotifyService.getPlaylistDetail(playlist.spotifyUrl);
|
||||||
playlist2Save = { ...playlist, name: details.preview.title };
|
playlist2Save = { ...playlist, name: detail.name };
|
||||||
|
this.createPlaylistFolderStructure(playlist2Save.name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
playlist2Save = { ...playlist, error: String(err) };
|
playlist2Save = { ...playlist, error: String(err) };
|
||||||
}
|
}
|
||||||
const savedPlaylist = await this.save(playlist2Save);
|
const savedPlaylist = await this.save(playlist2Save);
|
||||||
this.createPlaylistFolderStructure(savedPlaylist.name);
|
for (const track of detail.tracks ?? []) {
|
||||||
for (const track of details?.tracks ?? []) {
|
|
||||||
await this.trackService.create(
|
await this.trackService.create(
|
||||||
{
|
{
|
||||||
artist: track.artist,
|
artist: track.artist,
|
||||||
@@ -99,21 +100,21 @@ export class PlaylistService {
|
|||||||
async checkActivePlaylists(): Promise<void> {
|
async checkActivePlaylists(): Promise<void> {
|
||||||
const activePlaylists = await this.findAll({}, { active: true });
|
const activePlaylists = await this.findAll({}, { active: true });
|
||||||
for (const playlist of activePlaylists) {
|
for (const playlist of activePlaylists) {
|
||||||
let details;
|
let tracks = [];
|
||||||
try {
|
try {
|
||||||
details = await getDetails(playlist.spotifyUrl);
|
tracks = await this.spotifyService.getPlaylistTracks(playlist.spotifyUrl);
|
||||||
|
this.createPlaylistFolderStructure(playlist.name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.update(playlist.id, { ...playlist, error: String(err) });
|
await this.update(playlist.id, { ...playlist, error: String(err) });
|
||||||
}
|
}
|
||||||
this.createPlaylistFolderStructure(playlist.name);
|
for (const track of tracks ?? []) {
|
||||||
for (const track of details?.tracks ?? []) {
|
|
||||||
const track2Save = {
|
const track2Save = {
|
||||||
artist: track.artist,
|
artist: track.artist,
|
||||||
name: track.name,
|
name: track.name,
|
||||||
spotifyUrl: track.previewUrl,
|
spotifyUrl: track.previewUrl,
|
||||||
};
|
};
|
||||||
const isExist = !!(
|
const isExist = !!(
|
||||||
await this.trackService.findAll({
|
await this.trackService.getAll({
|
||||||
...track2Save,
|
...track2Save,
|
||||||
playlist: { id: playlist.id },
|
playlist: { id: playlist.id },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UtilsService } from './utils.service';
|
import { UtilsService } from './utils.service';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { SpotifyService } from './spotify.service';
|
||||||
|
import { YoutubeService } from './youtube.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
providers: [UtilsService],
|
providers: [UtilsService, SpotifyService, YoutubeService],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
exports: [UtilsService],
|
exports: [UtilsService, SpotifyService, YoutubeService],
|
||||||
})
|
})
|
||||||
export class SharedModule {}
|
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,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param('id') id: number,
|
@Param('id') id: number,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const track = await this.service.findOne(id);
|
const track = await this.service.get(id);
|
||||||
const fileName = this.service.getTrackFileName(track);
|
const fileName = this.service.getTrackFileName(track);
|
||||||
const readStream = createReadStream(
|
const readStream = createReadStream(
|
||||||
this.service.getFolderName(track, track.playlist),
|
this.service.getFolderName(track, track.playlist),
|
||||||
|
|||||||
@@ -5,14 +5,25 @@ 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';
|
||||||
import { SharedModule } from '../shared/shared.module';
|
import { SharedModule } from '../shared/shared.module';
|
||||||
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
|
import {
|
||||||
|
TrackDownloadProcessor,
|
||||||
|
} from './track-download.processor';
|
||||||
|
import {
|
||||||
|
TrackSearchProcessor,
|
||||||
|
} from './track-search.processor';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([TrackEntity]),
|
TypeOrmModule.forFeature([TrackEntity]),
|
||||||
|
BullModule.registerQueue(
|
||||||
|
{ name: 'track-search-processor' },
|
||||||
|
{ name: 'track-download-processor' },
|
||||||
|
),
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
],
|
],
|
||||||
providers: [TrackService],
|
providers: [TrackService, TrackDownloadProcessor, TrackSearchProcessor],
|
||||||
controllers: [TrackController],
|
controllers: [TrackController],
|
||||||
exports: [TrackService],
|
exports: [TrackService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,17 +3,15 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { TrackEntity, TrackStatusEnum } 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 * as yts from 'yt-search';
|
|
||||||
import * as ytdl from '@distube/ytdl-core';
|
|
||||||
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 { EnvironmentEnum } from '../environmentEnum';
|
import { EnvironmentEnum } from '../environmentEnum';
|
||||||
import { UtilsService } from '../shared/utils.service';
|
import { UtilsService } from '../shared/utils.service';
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
|
import { YoutubeService } from '../shared/youtube.service';
|
||||||
|
|
||||||
enum WsTrackOperation {
|
enum WsTrackOperation {
|
||||||
New = 'trackNew',
|
New = 'trackNew',
|
||||||
@@ -30,11 +28,14 @@ export class TrackService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(TrackEntity)
|
@InjectRepository(TrackEntity)
|
||||||
private repository: Repository<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 configService: ConfigService,
|
||||||
private readonly utilsService: UtilsService,
|
private readonly utilsService: UtilsService,
|
||||||
|
private readonly youtubeService: YoutubeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
findAll(
|
getAll(
|
||||||
where?: { [key: string]: any },
|
where?: { [key: string]: any },
|
||||||
relations: Record<string, boolean> = {},
|
relations: Record<string, boolean> = {},
|
||||||
): Promise<TrackEntity[]> {
|
): Promise<TrackEntity[]> {
|
||||||
@@ -45,7 +46,7 @@ export class TrackService {
|
|||||||
return this.repository.find({ where: { playlist: { id } } });
|
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'] });
|
return this.repository.findOne({ where: { id }, relations: ['playlist'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +57,9 @@ export class TrackService {
|
|||||||
|
|
||||||
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 });
|
||||||
|
await this.trackSearchQueue.add('', savedTrack, {
|
||||||
|
jobId: `id-${savedTrack.id}`,
|
||||||
|
});
|
||||||
this.io.emit(WsTrackOperation.New, {
|
this.io.emit(WsTrackOperation.New, {
|
||||||
track: savedTrack,
|
track: savedTrack,
|
||||||
playlistId: playlist.id,
|
playlistId: playlist.id,
|
||||||
@@ -68,85 +72,62 @@ export class TrackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async retry(id: number): Promise<void> {
|
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 });
|
await this.update(id, { ...track, status: TrackStatusEnum.New });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Interval(1_000)
|
async findOnYoutube(track: TrackEntity): Promise<void> {
|
||||||
async findOnYoutube(): Promise<void> {
|
if (!(await this.get(track.id))) {
|
||||||
const newTracks = await this.findAll({ status: TrackStatusEnum.New });
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
await this.update(track.id, {
|
||||||
|
...track,
|
||||||
@Interval(1_000)
|
status: TrackStatusEnum.Searching,
|
||||||
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)),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
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 {
|
getTrackFileName(track: TrackEntity): string {
|
||||||
@@ -159,13 +140,4 @@ export class TrackService {
|
|||||||
this.getTrackFileName(track),
|
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,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^18.0.0",
|
"@angular/animations": "^19.0.6",
|
||||||
"@angular/common": "^18.0.0",
|
"@angular/common": "^19.0.6",
|
||||||
"@angular/compiler": "^18.0.0",
|
"@angular/compiler": "^19.0.6",
|
||||||
"@angular/core": "^18.0.0",
|
"@angular/core": "^19.0.6",
|
||||||
"@angular/forms": "^18.0.0",
|
"@angular/forms": "^19.0.6",
|
||||||
"@angular/platform-browser": "^18.0.0",
|
"@angular/platform-browser": "^19.0.6",
|
||||||
"@angular/platform-browser-dynamic": "^18.0.0",
|
"@angular/platform-browser-dynamic": "^19.0.6",
|
||||||
"@angular/router": "^18.0.0",
|
"@angular/router": "^19.0.6",
|
||||||
|
"@distube/ytdl-core": "^4.15.9",
|
||||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||||
"@ngneat/elf": "^2.5.1",
|
"@ngneat/elf": "^2.5.1",
|
||||||
"@ngneat/elf-cli-ng": "^1.0.0",
|
"@ngneat/elf-cli-ng": "^1.0.0",
|
||||||
@@ -32,12 +33,12 @@
|
|||||||
"ngx-socket-io": "^4.7.0",
|
"ngx-socket-io": "^4.7.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.14.3"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^18.0.3",
|
"@angular-devkit/build-angular": "^19.0.7",
|
||||||
"@angular/cli": "^18.0.3",
|
"@angular/cli": "^19.0.7",
|
||||||
"@angular/compiler-cli": "^18.0.0",
|
"@angular/compiler-cli": "^19.0.6",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"jasmine-core": "~5.1.0",
|
"jasmine-core": "~5.1.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
@@ -45,6 +46,6 @@
|
|||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.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 {Component} from '@angular/core';
|
||||||
import {RouterOutlet} from '@angular/router';
|
|
||||||
import {FormsModule} from "@angular/forms";
|
import {FormsModule} from "@angular/forms";
|
||||||
import {CommonModule, NgFor} from "@angular/common";
|
import {CommonModule, NgFor} from "@angular/common";
|
||||||
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
||||||
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent],
|
||||||
imports: [CommonModule, RouterOutlet, FormsModule, NgFor, PlaylistBoxComponent],
|
templateUrl: './app.component.html',
|
||||||
templateUrl: './app.component.html',
|
styleUrl: './app.component.scss',
|
||||||
styleUrl: './app.component.scss'
|
standalone: true,
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
<a [href]="playlist.spotifyUrl"
|
<a [href]="playlist.spotifyUrl"
|
||||||
target="_blank"
|
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>
|
<i class="fa-brands fa-spotify"></i>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -14,17 +14,16 @@ const STATUS2CLASS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-playlist-box',
|
selector: 'app-playlist-box',
|
||||||
standalone: true,
|
imports: [
|
||||||
imports: [
|
CommonModule,
|
||||||
CommonModule,
|
AsyncPipe,
|
||||||
AsyncPipe,
|
NgIf,
|
||||||
NgForOf,
|
TrackListComponent
|
||||||
NgIf,
|
],
|
||||||
TrackListComponent
|
templateUrl: './playlist-box.component.html',
|
||||||
],
|
styleUrl: './playlist-box.component.scss',
|
||||||
templateUrl: './playlist-box.component.html',
|
standalone: true
|
||||||
styleUrl: './playlist-box.component.scss'
|
|
||||||
})
|
})
|
||||||
export class PlaylistBoxComponent {
|
export class PlaylistBoxComponent {
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,17 @@
|
|||||||
<span>{{ track.artist }} - {{ track.name }}</span>
|
<span>{{ track.artist }} - {{ track.name }}</span>
|
||||||
<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="has-text-primary margin-left" title="Spotify preview of track that will be downloaded">
|
||||||
<i class="fa-brands fa-spotify"></i>
|
<i class="fa-brands fa-spotify"></i>
|
||||||
</a>
|
</a>
|
||||||
<a [href]="track.youtubeUrl"
|
<a [href]="track.youtubeUrl"
|
||||||
target="_blank"
|
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>
|
<i class="fa-brands fa-youtube"></i>
|
||||||
</a>
|
</a>
|
||||||
<a *ngIf="track.status === trackStatuses.Completed"
|
<a *ngIf="track.status === trackStatuses.Completed"
|
||||||
href="api/track/download/{{track.id}}"
|
href="api/track/download/{{track.id}}"
|
||||||
class="is-color-info"
|
class="has-text-info"
|
||||||
title="Download downloaded and locally saved file"
|
title="Download downloaded and locally saved file"
|
||||||
download>
|
download>
|
||||||
<i class="fa-solid fa-download"></i>
|
<i class="fa-solid fa-download"></i>
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import {Observable} from "rxjs";
|
|||||||
import {Track, TrackStatusEnum} from "../../models/track";
|
import {Track, TrackStatusEnum} from "../../models/track";
|
||||||
|
|
||||||
@Component({
|
@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,
|
standalone: true,
|
||||||
imports: [CommonModule, NgFor, NgSwitch, NgSwitchCase],
|
|
||||||
templateUrl: './track-list.component.html',
|
|
||||||
styleUrl: './track-list.component.scss'
|
|
||||||
})
|
})
|
||||||
export class TrackListComponent {
|
export class TrackListComponent {
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user