23 changed files with 518 additions and 75 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules
dist
.git
.github
coverage
.vscode
*.log
downloads
config
.env
.env.*
README.md
assets
+23 -11
View File
@@ -6,15 +6,27 @@ RUN npm run build
FROM node:20.20.0-alpine FROM node:20.20.0-alpine
WORKDIR /spooty WORKDIR /spooty
COPY --from=builder /spooty/dist .
COPY --from=builder /spooty/src ./src RUN apk add --no-cache ffmpeg redis python3 py3-pip curl \
COPY --from=builder /spooty/package.json ./package.json && addgroup -S spooty \
COPY --from=builder /spooty/package-lock.json ./package-lock.json && adduser -S spooty -G spooty
COPY --from=builder /spooty/src/backend/.env.docker ./.env
RUN npm prune --production COPY --from=builder --chown=spooty:spooty /spooty/dist .
RUN rm -rf src package.json package-lock.json COPY --from=builder --chown=spooty:spooty /spooty/src ./src
RUN apk add --no-cache ffmpeg COPY --from=builder --chown=spooty:spooty /spooty/package.json ./package.json
RUN apk add --no-cache redis COPY --from=builder --chown=spooty:spooty /spooty/package-lock.json ./package-lock.json
RUN apk add --no-cache python3 py3-pip COPY --from=builder --chown=spooty:spooty /spooty/src/backend/.env.docker ./.env
RUN npm prune --production \
&& rm -rf src package.json package-lock.json \
&& mkdir -p /spooty/backend/downloads /spooty/backend/config /spooty/config \
&& chown -R spooty:spooty /spooty
USER spooty
EXPOSE 3000 EXPOSE 3000
CMD ["node", "backend/main.js"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "backend/main.js"]
+59 -2
View File
@@ -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",
+6
View File
@@ -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"
} }
} }
+6
View File
@@ -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
+6
View File
@@ -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
+5 -2
View File
@@ -2,10 +2,13 @@ import { Controller, Get } from '@nestjs/common';
@Controller() @Controller()
export class AppController { export class AppController {
constructor() {}
@Get() @Get()
getHello(): string { getHello(): string {
return 'ONLINE'; return 'ONLINE';
} }
@Get('health')
getHealth(): { status: string } {
return { status: 'ok' };
}
} }
+25 -1
View File
@@ -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,11 +45,30 @@ 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) => ({
defaultJobOptions: { defaultJobOptions: {
removeOnComplete: true, attempts: 2,
backoff: {
type: 'exponential',
delay: 30_000,
},
removeOnComplete: {
age: 3_600,
count: 1_000,
},
removeOnFail: {
age: 86_400,
count: 1_000,
},
timeout: 10 * 60_000,
}, },
connection: { connection: {
host: configService.get<string>(EnvironmentEnum.REDIS_HOST), host: configService.get<string>(EnvironmentEnum.REDIS_HOST),
@@ -66,6 +86,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 {}
+2
View File
@@ -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
View File
@@ -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);
} }
} }
+14 -3
View File
@@ -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 = {
+6 -1
View File
@@ -9,6 +9,12 @@ import type { Request } from 'express';
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean { canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
if (request.path === '/api/health') {
return true;
}
if (!this.authEnabled()) { if (!this.authEnabled()) {
return true; return true;
} }
@@ -20,7 +26,6 @@ export class AuthGuard implements CanActivate {
); );
} }
const request = context.switchToHttp().getRequest<Request>();
const providedToken = this.extractToken(request); const providedToken = this.extractToken(request);
if (providedToken !== expectedToken) { if (providedToken !== expectedToken) {
@@ -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,
});
}
}
+69 -24
View File
@@ -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,
);
} }
} }
@@ -1,17 +1,42 @@
import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq'; import { Job } from 'bullmq';
import { TrackService } from './track.service'; import { TrackService } from './track.service';
import { TrackEntity } from './track.entity'; import { TrackEntity } from './track.entity';
@Processor('track-download-processor') 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', {
concurrency: 1,
})
export class TrackDownloadProcessor extends WorkerHost { export class TrackDownloadProcessor extends WorkerHost {
private readonly logger = new Logger(TrackDownloadProcessor.name);
constructor(private readonly trackService: TrackService) { constructor(private readonly trackService: TrackService) {
super(); super();
} }
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); if (!job.data?.id) {
this.logger.warn(`Skipping malformed download job ${job.id}`);
return;
}
const maxPerMinute = getDownloadsPerMinute();
const sleepMs = Math.floor(60000 / maxPerMinute); const sleepMs = Math.floor(60000 / maxPerMinute);
this.logger.debug(
`Processing download job ${job.id} for track ${job.data.id}; pacing ${sleepMs}ms`,
);
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);
} }
@@ -1,15 +1,26 @@
import { Processor, WorkerHost } from '@nestjs/bullmq'; import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Logger } from '@nestjs/common';
import { Job } from 'bullmq'; import { Job } from 'bullmq';
import { TrackService } from './track.service'; import { TrackService } from './track.service';
import { TrackEntity } from './track.entity'; import { TrackEntity } from './track.entity';
@Processor('track-search-processor') @Processor('track-search-processor', {
concurrency: 2,
})
export class TrackSearchProcessor extends WorkerHost { export class TrackSearchProcessor extends WorkerHost {
private readonly logger = new Logger(TrackSearchProcessor.name);
constructor(private readonly trackService: TrackService) { constructor(private readonly trackService: TrackService) {
super(); super();
} }
async process(job: Job<TrackEntity, void, string>): Promise<void> { async process(job: Job<TrackEntity, void, string>): Promise<void> {
if (!job.data?.id) {
this.logger.warn(`Skipping malformed search job ${job.id}`);
return;
}
this.logger.debug(`Processing search job ${job.id} for track ${job.data.id}`);
await this.trackService.findOnYoutube(job.data); await this.trackService.findOnYoutube(job.data);
} }
} }
+32 -9
View File
@@ -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);
} }
} }
+70 -12
View File
@@ -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',
@@ -57,9 +58,7 @@ 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, { await this.enqueueSearch(savedTrack.id);
jobId: `id-${savedTrack.id}`,
});
this.io.emit(WsTrackOperation.New, { this.io.emit(WsTrackOperation.New, {
track: savedTrack, track: savedTrack,
playlistId: playlist.id, playlistId: playlist.id,
@@ -73,14 +72,30 @@ 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}` });
await this.update(id, { ...track, status: TrackStatusEnum.New }); if (!track) {
this.logger.warn(`Cannot retry missing track ${id}`);
return;
}
await this.enqueueSearch(id);
await this.update(id, {
...track,
error: null,
status: TrackStatusEnum.New,
});
} }
async findOnYoutube(track: TrackEntity): Promise<void> { async findOnYoutube(track: TrackEntity): Promise<void> {
if (!(await this.get(track.id))) { const dbTrack = await this.get(track.id);
if (!dbTrack) {
this.logger.warn(`Skipping search for missing track ${track.id}`);
return; return;
} }
track = dbTrack;
await this.update(track.id, { await this.update(track.id, {
...track, ...track,
status: TrackStatusEnum.Searching, status: TrackStatusEnum.Searching,
@@ -96,20 +111,63 @@ 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.trackDownloadQueue.add('', updatedTrack, {
jobId: `id-${updatedTrack.id}`,
});
await this.update(track.id, updatedTrack); await this.update(track.id, updatedTrack);
await this.enqueueDownload(updatedTrack.id);
}
private async enqueueSearch(id: number): Promise<void> {
const track = await this.get(id);
if (!track) {
this.logger.warn(`Cannot enqueue search for missing track ${id}`);
return;
}
const jobId = `search-${id}`;
const existingJob = await this.trackSearchQueue.getJob(jobId);
if (existingJob) {
this.logger.warn(`Search job already exists for track ${id}`);
return;
}
await this.trackSearchQueue.add('search-track', { id }, { jobId });
}
private async enqueueDownload(id: number): Promise<void> {
const track = await this.get(id);
if (!track) {
this.logger.warn(`Cannot enqueue download for missing track ${id}`);
return;
}
const jobId = `download-${id}`;
const existingJob = await this.trackDownloadQueue.getJob(jobId);
if (existingJob) {
this.logger.warn(`Download job already exists for track ${id}`);
return;
}
await this.trackDownloadQueue.add('download-track', { id }, { jobId });
} }
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 +201,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 = {
+14
View File
@@ -30,9 +30,23 @@ export class AppComponent {
private readonly playlistService: PlaylistService, private readonly playlistService: PlaylistService,
private readonly versionService: VersionService, private readonly versionService: VersionService,
) { ) {
this.bootstrapAuthTokenFromUrl();
this.fetchPlaylists(); this.fetchPlaylists();
} }
private bootstrapAuthTokenFromUrl(): void {
const url = new URL(window.location.href);
const token = url.searchParams.get('token');
if (!token) {
return;
}
localStorage.setItem('spooty_auth_token', token);
url.searchParams.delete('token');
window.history.replaceState({}, document.title, url.toString());
}
fetchPlaylists(): void { fetchPlaylists(): void {
this.playlistService.fetch(); this.playlistService.fetch();
} }