9 changed files with 157 additions and 30 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"]
+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' };
}
} }
+14 -1
View File
@@ -55,7 +55,20 @@ import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
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),
+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) {
@@ -1,4 +1,5 @@
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';
@@ -13,15 +14,29 @@ function getDownloadsPerMinute(): number {
return Math.max(1, Math.min(30, Math.floor(parsed))); return Math.max(1, Math.min(30, Math.floor(parsed)));
} }
@Processor('track-download-processor') @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> {
if (!job.data?.id) {
this.logger.warn(`Skipping malformed download job ${job.id}`);
return;
}
const maxPerMinute = getDownloadsPerMinute(); 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);
} }
} }
+54 -13
View File
@@ -58,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,
@@ -74,23 +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);
const existingJob = await this.trackSearchQueue.getJob(`id-${id}`);
if (existingJob) { if (!track) {
this.logger.warn(`Search job already exists for track ${id}`); this.logger.warn(`Cannot retry missing track ${id}`);
return; return;
} }
await this.trackSearchQueue.add('', track, { await this.enqueueSearch(id);
jobId: `id-${id}`, await this.update(id, {
...track,
error: null,
status: TrackStatusEnum.New,
}); });
await this.update(id, { ...track, 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,
@@ -114,9 +119,45 @@ export class TrackService {
} }
await this.update(track.id, updatedTrack); await this.update(track.id, updatedTrack);
await this.trackDownloadQueue.add('', updatedTrack, { await this.enqueueDownload(updatedTrack.id);
jobId: `id-${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> {
+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();
} }