Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9e4cf5d07 | ||
|
|
41f5a80ffb | ||
|
|
0f9b1f9477 |
Generated
+59
-2
@@ -11,6 +11,12 @@
|
|||||||
"src/backend",
|
"src/backend",
|
||||||
"src/frontend"
|
"src/frontend"
|
||||||
],
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"helmet": "^8.1.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@release-it/bumper": "^7.0.1",
|
"@release-it/bumper": "^7.0.1",
|
||||||
"auto-changelog": "^2.5.0",
|
"auto-changelog": "^2.5.0",
|
||||||
@@ -6054,6 +6060,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/throttler": {
|
||||||
|
"version": "6.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz",
|
||||||
|
"integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
|
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
|
||||||
|
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/typeorm": {
|
"node_modules/@nestjs/typeorm": {
|
||||||
"version": "10.0.2",
|
"version": "10.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz",
|
||||||
@@ -8241,6 +8257,11 @@
|
|||||||
"@types/superagent": "^8.1.0"
|
"@types/superagent": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/validator": {
|
||||||
|
"version": "13.15.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||||
|
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA=="
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -10472,6 +10493,21 @@
|
|||||||
"node": ">= 0.3.0"
|
"node": ">= 0.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/class-transformer": {
|
||||||
|
"version": "0.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||||
|
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="
|
||||||
|
},
|
||||||
|
"node_modules/class-validator": {
|
||||||
|
"version": "0.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz",
|
||||||
|
"integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/validator": "^13.15.3",
|
||||||
|
"libphonenumber-js": "^1.11.1",
|
||||||
|
"validator": "^13.15.22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clean-stack": {
|
"node_modules/clean-stack": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
|
||||||
@@ -14399,6 +14435,14 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/helmet": {
|
||||||
|
"version": "8.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||||
|
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/himalaya": {
|
"node_modules/himalaya": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/himalaya/-/himalaya-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/himalaya/-/himalaya-1.1.1.tgz",
|
||||||
@@ -16929,6 +16973,11 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/libphonenumber-js": {
|
||||||
|
"version": "1.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.1.tgz",
|
||||||
|
"integrity": "sha512-GEw0GLL7YUUA6nv21IsCvVjtI5Ejn84sjbdfQ9KxdbqEVOk1PZh7xejn01EEiniKw+dBeCfim+8MGeuvVuE2BA=="
|
||||||
|
},
|
||||||
"node_modules/license-webpack-plugin": {
|
"node_modules/license-webpack-plugin": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/license-webpack-plugin/-/license-webpack-plugin-4.0.2.tgz",
|
||||||
@@ -24476,6 +24525,14 @@
|
|||||||
"node": "^18.17.0 || >=20.5.0"
|
"node": "^18.17.0 || >=20.5.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/validator": {
|
||||||
|
"version": "13.15.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
|
||||||
|
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@@ -25704,7 +25761,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"src/backend": {
|
"src/backend": {
|
||||||
"version": "2.3.4",
|
"version": "2.4.2",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/bullmq": "^10.2.3",
|
"@nestjs/bullmq": "^10.2.3",
|
||||||
@@ -25772,7 +25829,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"src/frontend": {
|
"src/frontend": {
|
||||||
"version": "2.3.4",
|
"version": "2.4.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^19.0.6",
|
"@angular/animations": "^19.0.6",
|
||||||
"@angular/common": "^19.0.6",
|
"@angular/common": "^19.0.6",
|
||||||
|
|||||||
@@ -30,5 +30,11 @@
|
|||||||
"release-it": "^18.1.2",
|
"release-it": "^18.1.2",
|
||||||
"release-it-docker-plugin": "^2.0.0",
|
"release-it-docker-plugin": "^2.0.0",
|
||||||
"rimraf": "^6.0.1"
|
"rimraf": "^6.0.1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"helmet": "^8.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,3 +19,9 @@ YT_DOWNLOADS_PER_MINUTE=3
|
|||||||
# Optional local hardening. Set AUTH_ENABLED=true and choose a strong token.
|
# Optional local hardening. Set AUTH_ENABLED=true and choose a strong token.
|
||||||
AUTH_ENABLED=false
|
AUTH_ENABLED=false
|
||||||
SPOOTY_AUTH_TOKEN=
|
SPOOTY_AUTH_TOKEN=
|
||||||
|
|
||||||
|
|
||||||
|
# Optional frontend origin restriction
|
||||||
|
CORS_ORIGIN=http://localhost:4200
|
||||||
|
|
||||||
|
NODE_ENV=development
|
||||||
|
|||||||
@@ -17,3 +17,9 @@ YT_DOWNLOADS_PER_MINUTE=3
|
|||||||
# Optional local hardening. Set AUTH_ENABLED=true and choose a strong token.
|
# Optional local hardening. Set AUTH_ENABLED=true and choose a strong token.
|
||||||
AUTH_ENABLED=false
|
AUTH_ENABLED=false
|
||||||
SPOOTY_AUTH_TOKEN=
|
SPOOTY_AUTH_TOKEN=
|
||||||
|
|
||||||
|
|
||||||
|
# Optional frontend origin restriction
|
||||||
|
CORS_ORIGIN=http://localhost:4200
|
||||||
|
|
||||||
|
NODE_ENV=production
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { EnvironmentEnum } from './environmentEnum';
|
|||||||
import { BullModule } from '@nestjs/bullmq';
|
import { BullModule } from '@nestjs/bullmq';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { AuthGuard } from './shared/auth.guard';
|
import { AuthGuard } from './shared/auth.guard';
|
||||||
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -44,6 +45,12 @@ import { AuthGuard } from './shared/auth.guard';
|
|||||||
],
|
],
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
}),
|
}),
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
ttl: 60000,
|
||||||
|
limit: 60,
|
||||||
|
},
|
||||||
|
]),
|
||||||
BullModule.forRootAsync({
|
BullModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
useFactory: async (configService: ConfigService) => ({
|
useFactory: async (configService: ConfigService) => ({
|
||||||
@@ -66,6 +73,10 @@ import { AuthGuard } from './shared/auth.guard';
|
|||||||
provide: APP_GUARD,
|
provide: APP_GUARD,
|
||||||
useClass: AuthGuard,
|
useClass: AuthGuard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ export enum EnvironmentEnum {
|
|||||||
YT_COOKIES_FILE = 'YT_COOKIES_FILE',
|
YT_COOKIES_FILE = 'YT_COOKIES_FILE',
|
||||||
AUTH_ENABLED = 'AUTH_ENABLED',
|
AUTH_ENABLED = 'AUTH_ENABLED',
|
||||||
SPOOTY_AUTH_TOKEN = 'SPOOTY_AUTH_TOKEN',
|
SPOOTY_AUTH_TOKEN = 'SPOOTY_AUTH_TOKEN',
|
||||||
|
CORS_ORIGIN = 'CORS_ORIGIN',
|
||||||
|
NODE_ENV = 'NODE_ENV',
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-1
@@ -1,8 +1,11 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
|
||||||
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 { exec } from 'child_process';
|
||||||
|
import helmet from 'helmet';
|
||||||
import { EnvironmentEnum } from './environmentEnum';
|
import { EnvironmentEnum } from './environmentEnum';
|
||||||
|
|
||||||
function envFlag(name: string): boolean {
|
function envFlag(name: string): boolean {
|
||||||
@@ -31,7 +34,41 @@ async function bootstrap() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
bodyParser: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transform: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
crossOriginResourcePolicy: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const corsOrigin =
|
||||||
|
process.env[EnvironmentEnum.CORS_ORIGIN] || 'http://localhost:4200';
|
||||||
|
|
||||||
|
app.enableCors({
|
||||||
|
origin: corsOrigin,
|
||||||
|
credentials: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.env[EnvironmentEnum.NODE_ENV] === 'production' &&
|
||||||
|
!envFlag(EnvironmentEnum.AUTH_ENABLED)
|
||||||
|
) {
|
||||||
|
console.warn('[SECURITY] AUTH_ENABLED is false while NODE_ENV=production');
|
||||||
|
}
|
||||||
|
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
await app.listen(process.env.PORT || 3000);
|
await app.listen(process.env.PORT || 3000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsString, IsUrl, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreatePlaylistDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2048)
|
||||||
|
@IsUrl({
|
||||||
|
require_protocol: true,
|
||||||
|
protocols: ['https'],
|
||||||
|
})
|
||||||
|
spotifyUrl: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdatePlaylistDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
@@ -4,11 +4,14 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PlaylistService } from './playlist.service';
|
import { PlaylistService } from './playlist.service';
|
||||||
import { PlaylistEntity } from './playlist.entity';
|
import { PlaylistEntity } from './playlist.entity';
|
||||||
|
import { CreatePlaylistDto } from './dto/create-playlist.dto';
|
||||||
|
import { UpdatePlaylistDto } from './dto/update-playlist.dto';
|
||||||
|
|
||||||
@Controller('playlist')
|
@Controller('playlist')
|
||||||
export class PlaylistController {
|
export class PlaylistController {
|
||||||
@@ -20,25 +23,27 @@ export class PlaylistController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
async create(@Body() playlist: PlaylistEntity): Promise<void> {
|
async create(@Body() playlist: CreatePlaylistDto): Promise<void> {
|
||||||
await this.service.create(playlist);
|
await this.service.create(playlist as PlaylistEntity);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
update(
|
update(
|
||||||
@Param('id') id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() playlist: Partial<PlaylistEntity>,
|
@Body() playlist: UpdatePlaylistDto,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return this.service.update(id, playlist);
|
return this.service.update(id, playlist);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id') id: number): Promise<void> {
|
remove(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||||
return this.service.remove(id);
|
return this.service.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('retry/:id')
|
@Get('retry/:id')
|
||||||
retryFailedOfPlaylist(@Param('id') id: number): Promise<void> {
|
retryFailedOfPlaylist(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
): Promise<void> {
|
||||||
return this.service.retryFailedOfPlaylist(id);
|
return this.service.retryFailedOfPlaylist(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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';
|
||||||
import { SpotifyService } from '../shared/spotify.service';
|
import { SpotifyService } from '../shared/spotify.service';
|
||||||
|
import { toSafeErrorMessage } from '../shared/errors/safe-error';
|
||||||
|
|
||||||
enum WsPlaylistOperation {
|
enum WsPlaylistOperation {
|
||||||
New = 'playlistNew',
|
New = 'playlistNew',
|
||||||
@@ -77,7 +78,11 @@ export class PlaylistService {
|
|||||||
// Don't create folder structure for individual tracks - they go in root
|
// Don't create folder structure for individual tracks - they go in root
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Error getting track details: ${err}`);
|
this.logger.error(`Error getting track details: ${err}`);
|
||||||
playlist2Save = { ...playlist, error: String(err), isTrack: true };
|
playlist2Save = {
|
||||||
|
...playlist,
|
||||||
|
error: toSafeErrorMessage(err),
|
||||||
|
isTrack: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const savedPlaylist = await this.save(playlist2Save);
|
const savedPlaylist = await this.save(playlist2Save);
|
||||||
|
|
||||||
@@ -117,7 +122,10 @@ export class PlaylistService {
|
|||||||
this.createPlaylistFolderStructure(playlist2Save.name);
|
this.createPlaylistFolderStructure(playlist2Save.name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(`Error getting playlist details: ${err}`);
|
this.logger.error(`Error getting playlist details: ${err}`);
|
||||||
playlist2Save = { ...playlist, error: String(err) };
|
playlist2Save = {
|
||||||
|
...playlist,
|
||||||
|
error: toSafeErrorMessage(err),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const savedPlaylist = await this.save(playlist2Save);
|
const savedPlaylist = await this.save(playlist2Save);
|
||||||
|
|
||||||
@@ -224,7 +232,10 @@ export class PlaylistService {
|
|||||||
);
|
);
|
||||||
this.createPlaylistFolderStructure(playlist.name);
|
this.createPlaylistFolderStructure(playlist.name);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await this.update(playlist.id, { ...playlist, error: String(err) });
|
await this.update(playlist.id, {
|
||||||
|
...playlist,
|
||||||
|
error: toSafeErrorMessage(err),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
for (const track of tracks ?? []) {
|
for (const track of tracks ?? []) {
|
||||||
const track2Save = {
|
const track2Save = {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function toSafeErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return sanitize(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return sanitize(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown error';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitize(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\/home\/[^\s]+/g, '[path]')
|
||||||
|
.replace(/\/root\/[^\s]+/g, '[path]')
|
||||||
|
.replace(/token=[^\s&]+/gi, 'token=[redacted]')
|
||||||
|
.replace(/authorization:[^\n]+/gi, 'authorization:[redacted]')
|
||||||
|
.slice(0, 500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
ArgumentsHost,
|
||||||
|
Catch,
|
||||||
|
ExceptionFilter,
|
||||||
|
HttpException,
|
||||||
|
HttpStatus,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||||
|
|
||||||
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
|
const ctx = host.switchToHttp();
|
||||||
|
|
||||||
|
const response = ctx.getResponse<Response>();
|
||||||
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
|
const status =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.getStatus()
|
||||||
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
|
const message =
|
||||||
|
exception instanceof HttpException
|
||||||
|
? exception.message
|
||||||
|
: 'Internal server error';
|
||||||
|
|
||||||
|
this.logger.error(
|
||||||
|
`${request.method} ${request.url} -> ${status}: ${String(exception)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
response.status(status).json({
|
||||||
|
statusCode: status,
|
||||||
|
message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
path: request.url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,14 @@ const HEADERS = {
|
|||||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ALLOWED_YOUTUBE_HOSTS = new Set([
|
||||||
|
'youtube.com',
|
||||||
|
'www.youtube.com',
|
||||||
|
'm.youtube.com',
|
||||||
|
'music.youtube.com',
|
||||||
|
'youtu.be',
|
||||||
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class YoutubeService {
|
export class YoutubeService {
|
||||||
private readonly logger = new Logger(TrackService.name);
|
private readonly logger = new Logger(TrackService.name);
|
||||||
@@ -20,10 +28,37 @@ export class YoutubeService {
|
|||||||
constructor(private readonly configService: ConfigService) {}
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
async findOnYoutubeOne(artist: string, name: string): Promise<string> {
|
async findOnYoutubeOne(artist: string, name: string): Promise<string> {
|
||||||
this.logger.debug(`Searching ${artist} - ${name} on YT`);
|
const query = `${artist} - ${name}`;
|
||||||
const url = (await yts(`${artist} - ${name}`)).videos[0].url;
|
this.logger.debug(`Searching ${query} on YT`);
|
||||||
this.logger.debug(`Found ${artist} - ${name} on ${url}`);
|
|
||||||
return url;
|
const result = await yts(query);
|
||||||
|
const firstVideo = result.videos?.[0];
|
||||||
|
|
||||||
|
if (!firstVideo?.url) {
|
||||||
|
throw new Error(`No YouTube result found for: ${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertValidYoutubeUrl(firstVideo.url);
|
||||||
|
this.logger.debug(`Found ${query} on ${firstVideo.url}`);
|
||||||
|
return firstVideo.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertValidYoutubeUrl(url: string): void {
|
||||||
|
let parsed: URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid YouTube URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||||
|
throw new Error('Rejected non-HTTP YouTube URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_YOUTUBE_HOSTS.has(parsed.hostname.toLowerCase())) {
|
||||||
|
throw new Error(`Rejected non-YouTube URL: ${parsed.hostname}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCookiesOptions(): {
|
private getCookiesOptions(): {
|
||||||
@@ -51,10 +86,13 @@ export class YoutubeService {
|
|||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`,
|
`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!track.youtubeUrl) {
|
if (!track.youtubeUrl) {
|
||||||
this.logger.error('youtubeUrl is null or undefined');
|
throw new Error('youtubeUrl is null or undefined');
|
||||||
throw Error('youtubeUrl is null or undefined');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.assertValidYoutubeUrl(track.youtubeUrl);
|
||||||
|
|
||||||
const ytdlp = new YtDlp();
|
const ytdlp = new YtDlp();
|
||||||
await ytdlp.downloadAudio(
|
await ytdlp.downloadAudio(
|
||||||
track.youtubeUrl,
|
track.youtubeUrl,
|
||||||
@@ -67,6 +105,7 @@ export class YoutubeService {
|
|||||||
audioQuality: this.configService.get<string>('QUALITY'),
|
audioQuality: this.configService.get<string>('QUALITY'),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Downloaded ${track.artist} - ${track.name} to ${output}`,
|
`Downloaded ${track.artist} - ${track.name} to ${output}`,
|
||||||
);
|
);
|
||||||
@@ -78,24 +117,30 @@ export class YoutubeService {
|
|||||||
title: string,
|
title: string,
|
||||||
artist: string,
|
artist: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (coverUrl) {
|
if (!coverUrl) {
|
||||||
const res = await fetch(coverUrl);
|
return;
|
||||||
const arrayBuf = await res.arrayBuffer();
|
|
||||||
const imageBuffer = Buffer.from(arrayBuf);
|
|
||||||
|
|
||||||
NodeID3.write(
|
|
||||||
{
|
|
||||||
title,
|
|
||||||
artist,
|
|
||||||
APIC: {
|
|
||||||
mime: 'image/jpeg',
|
|
||||||
type: { id: 3, name: 'front cover' },
|
|
||||||
description: 'cover',
|
|
||||||
imageBuffer,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
folderName,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const res = await fetch(coverUrl);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch cover art: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuf = await res.arrayBuffer();
|
||||||
|
const imageBuffer = Buffer.from(arrayBuf);
|
||||||
|
|
||||||
|
NodeID3.write(
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
APIC: {
|
||||||
|
mime: 'image/jpeg',
|
||||||
|
type: { id: 3, name: 'front cover' },
|
||||||
|
description: 'cover',
|
||||||
|
imageBuffer,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
folderName,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { Job } from 'bullmq';
|
|||||||
import { TrackService } from './track.service';
|
import { TrackService } from './track.service';
|
||||||
import { TrackEntity } from './track.entity';
|
import { TrackEntity } from './track.entity';
|
||||||
|
|
||||||
|
function getDownloadsPerMinute(): number {
|
||||||
|
const parsed = Number(process.env.YT_DOWNLOADS_PER_MINUTE || 3);
|
||||||
|
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(1, Math.min(30, Math.floor(parsed)));
|
||||||
|
}
|
||||||
|
|
||||||
@Processor('track-download-processor')
|
@Processor('track-download-processor')
|
||||||
export class TrackDownloadProcessor extends WorkerHost {
|
export class TrackDownloadProcessor extends WorkerHost {
|
||||||
constructor(private readonly trackService: TrackService) {
|
constructor(private readonly trackService: TrackService) {
|
||||||
@@ -10,7 +20,7 @@ export class TrackDownloadProcessor extends WorkerHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async process(job: Job<TrackEntity, void>): Promise<void> {
|
async process(job: Job<TrackEntity, void>): Promise<void> {
|
||||||
const maxPerMinute = Number(process.env.YT_DOWNLOADS_PER_MINUTE || 3);
|
const maxPerMinute = getDownloadsPerMinute();
|
||||||
const sleepMs = Math.floor(60000 / maxPerMinute);
|
const sleepMs = Math.floor(60000 / maxPerMinute);
|
||||||
await new Promise((res) => setTimeout(res, sleepMs));
|
await new Promise((res) => setTimeout(res, sleepMs));
|
||||||
await this.trackService.downloadFromYoutube(job.data);
|
await this.trackService.downloadFromYoutube(job.data);
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
Res,
|
Res,
|
||||||
StreamableFile,
|
StreamableFile,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { TrackService } from './track.service';
|
import { TrackService } from './track.service';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream, existsSync } from 'fs';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { TrackEntity } from './track.entity';
|
import { TrackEntity } from './track.entity';
|
||||||
@@ -20,33 +22,54 @@ export class TrackController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('playlist/:id')
|
@Get('playlist/:id')
|
||||||
getAllByPlaylist(@Param('id') playlistId: number): Promise<TrackEntity[]> {
|
getAllByPlaylist(
|
||||||
|
@Param('id', ParseIntPipe) playlistId: number,
|
||||||
|
): Promise<TrackEntity[]> {
|
||||||
return this.service.getAllByPlaylist(playlistId);
|
return this.service.getAllByPlaylist(playlistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('download/:id')
|
@Get('download/:id')
|
||||||
async getFile(
|
async getFile(
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
@Param('id') id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
): Promise<StreamableFile> {
|
): Promise<StreamableFile> {
|
||||||
const track = await this.service.get(id);
|
const track = await this.service.get(id);
|
||||||
|
|
||||||
|
if (!track || !track.playlist) {
|
||||||
|
throw new NotFoundException('Track not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = this.service.getFolderName(track, track.playlist);
|
||||||
|
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
throw new NotFoundException('Track file not found');
|
||||||
|
}
|
||||||
|
|
||||||
const fileName = this.service.getTrackFileName(track);
|
const fileName = this.service.getTrackFileName(track);
|
||||||
const readStream = createReadStream(
|
|
||||||
this.service.getFolderName(track, track.playlist),
|
const readStream = createReadStream(filePath);
|
||||||
);
|
|
||||||
res.set({
|
res.set({
|
||||||
'Content-Disposition': `attachment; filename="${encodeURIComponent(fileName)}`,
|
'Content-Disposition':
|
||||||
|
`attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
});
|
});
|
||||||
|
|
||||||
return new StreamableFile(readStream);
|
return new StreamableFile(readStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id') id: number): Promise<void> {
|
remove(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
): Promise<void> {
|
||||||
return this.service.remove(id);
|
return this.service.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('retry/:id')
|
@Get('retry/:id')
|
||||||
retry(@Param('id') id: number): Promise<void> {
|
retry(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
): Promise<void> {
|
||||||
return this.service.retry(id);
|
return this.service.retry(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { UtilsService } from '../shared/utils.service';
|
|||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { YoutubeService } from '../shared/youtube.service';
|
import { YoutubeService } from '../shared/youtube.service';
|
||||||
|
import { toSafeErrorMessage } from '../shared/errors/safe-error';
|
||||||
|
|
||||||
enum WsTrackOperation {
|
enum WsTrackOperation {
|
||||||
New = 'trackNew',
|
New = 'trackNew',
|
||||||
@@ -73,7 +74,16 @@ export class TrackService {
|
|||||||
|
|
||||||
async retry(id: number): Promise<void> {
|
async retry(id: number): Promise<void> {
|
||||||
const track = await this.get(id);
|
const track = await this.get(id);
|
||||||
await this.trackSearchQueue.add('', track, { jobId: `id-${id}` });
|
const existingJob = await this.trackSearchQueue.getJob(`id-${id}`);
|
||||||
|
|
||||||
|
if (existingJob) {
|
||||||
|
this.logger.warn(`Search job already exists for track ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.trackSearchQueue.add('', track, {
|
||||||
|
jobId: `id-${id}`,
|
||||||
|
});
|
||||||
await this.update(id, { ...track, status: TrackStatusEnum.New });
|
await this.update(id, { ...track, status: TrackStatusEnum.New });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,20 +106,27 @@ export class TrackService {
|
|||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
updatedTrack = {
|
updatedTrack = {
|
||||||
...track,
|
...track,
|
||||||
error: String(err),
|
error: toSafeErrorMessage(err),
|
||||||
status: TrackStatusEnum.Error,
|
status: TrackStatusEnum.Error,
|
||||||
};
|
};
|
||||||
|
await this.update(track.id, updatedTrack);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.update(track.id, updatedTrack);
|
||||||
await this.trackDownloadQueue.add('', updatedTrack, {
|
await this.trackDownloadQueue.add('', updatedTrack, {
|
||||||
jobId: `id-${updatedTrack.id}`,
|
jobId: `id-${updatedTrack.id}`,
|
||||||
});
|
});
|
||||||
await this.update(track.id, updatedTrack);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadFromYoutube(track: TrackEntity): Promise<void> {
|
async downloadFromYoutube(track: TrackEntity): Promise<void> {
|
||||||
if (!(await this.get(track.id))) {
|
const dbTrack = await this.get(track.id);
|
||||||
|
if (!dbTrack) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
track = dbTrack;
|
||||||
|
|
||||||
if (!track.name || !track.artist || !track.playlist) {
|
if (!track.name || !track.artist || !track.playlist) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Track or playlist field is null or undefined: name=${track.name}, artist=${track.artist}, playlist=${track.playlist ? 'ok' : 'null'}`,
|
`Track or playlist field is null or undefined: name=${track.name}, artist=${track.artist}, playlist=${track.playlist ? 'ok' : 'null'}`,
|
||||||
@@ -143,7 +160,7 @@ export class TrackService {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
error = String(err);
|
error = toSafeErrorMessage(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedTrack = {
|
const updatedTrack = {
|
||||||
|
|||||||
Reference in New Issue
Block a user