Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1b040a8b1 | ||
|
|
abc63c0cec | ||
|
|
33f077ed1e | ||
|
|
5fb84af635 |
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
coverage
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
downloads
|
||||||
|
config
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
README.md
|
||||||
|
assets
|
||||||
+22
-10
@@ -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
|
||||||
|
|
||||||
|
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"]
|
CMD ["node", "backend/main.js"]
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user