18 changed files with 2064 additions and 2038 deletions
+7 -1
View File
@@ -1,6 +1,6 @@
# Changelog
All notable changes to the hardened Spooty branch are documented here.
All notable changes to Jarri Spooty are documented here.
This project follows a practical chronological changelog rather than autogenerated dependency noise.
@@ -64,6 +64,12 @@ This project follows a practical chronological changelog rather than autogenerat
- Added isolated Spotify credential storage
- Added safer cookie handling flow
- Improved Docker secret isolation documentation
- Removed unused downloader dependencies to reduce transitive attack surface
- Hardened download filename sanitization
- Hardened remote cover-art fetching with protocol, MIME, timeout, and size checks
- Added yt-dlp subprocess timeout and single-settlement handling
- Shaped track websocket payloads to avoid leaking internal entity fields
- Normalized visible project naming to Jarri Spooty
### Frontend
+11 -7
View File
@@ -1,8 +1,8 @@
# Spooty Hardened
# Jarri Spooty
Self-hosted Spotify playlist and track downloader built with NestJS + Angular.
Spooty does not download audio from Spotify itself.
Jarri Spooty does not download audio from Spotify itself.
It retrieves metadata from Spotify and locates matching audio on YouTube.
This hardened branch focuses on:
@@ -21,6 +21,8 @@ This hardened branch focuses on:
- Persistent SQLite state across container restarts
- Deterministic Docker config persistence
- Improved operational observability
- Reduced unused dependency surface
- Hardened filename, cover-art, subprocess, and websocket boundaries
---
@@ -40,6 +42,8 @@ This hardened branch focuses on:
- Automatic YouTube retry/fallback handling
- Failed YouTube candidate rejection memory
- Deterministic yt-dlp error classification
- Hardened cover-art validation and embedding
- Explicit client-facing websocket payload shaping
---
@@ -136,7 +140,7 @@ docker run --rm -p 3000:3000 \
-v "$PWD/downloads:/spooty/backend/downloads" \
-v "$PWD/spooty-config:/spooty/backend/config" \
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
spootyfy-hardened:local
jarri-spooty:local
```
Open:
@@ -158,8 +162,8 @@ Then:
```yaml
services:
spooty:
image: spootyfy-hardened:local
container_name: spooty
image: jarri-spooty:local
container_name: jarri-spooty
restart: unless-stopped
ports:
@@ -204,7 +208,7 @@ services:
```bash
npm install
npm run build
docker build -t spootyfy-hardened:local .
docker build -t jarri-spooty:local .
```
---
@@ -493,7 +497,7 @@ docker run --rm -p 3000:3000 \
-v "$PWD/downloads:/spooty/backend/downloads" \
-v "$PWD/spooty-config:/spooty/backend/config" \
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
spootyfy-hardened:local
jarri-spooty:local
```
---
+1833 -1982
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -17,8 +17,7 @@
"clean": "rimraf dist",
"changelog": "auto-changelog -p",
"release": "release-it",
"commit": "cz",
"check:lib": "npm-check-updates -w backend -f ytdlp-nodejs -u"
"commit": "cz"
},
"devDependencies": {
"@release-it/bumper": "^7.0.1",
+1 -6
View File
@@ -31,18 +31,13 @@
"@nestjs/serve-static": "^4.0.2",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.3.10",
"@types/fluent-ffmpeg": "^2.1.24",
"@types/yt-search": "^2.10.3",
"bullmq": "^5.31.2",
"fluent-ffmpeg": "^2.1.3",
"isomorphic-unfetch": "^4.0.2",
"node-id3": "^0.2.9",
"reflect-metadata": "^0.2.0",
"rxjs": "7.8.0",
"spotify-url-info": "^3.2.18",
"sqlite3": "^5.1.7",
"yt-search": "^2.12.1",
"ytdlp-nodejs": "^3.4.4"
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",
+11 -2
View File
@@ -4,7 +4,7 @@ import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
import { AppModule } from './app.module';
import * as fs from 'fs';
import { resolve } from 'path';
import { exec } from 'child_process';
import { spawn } from 'child_process';
import helmet from 'helmet';
import { EnvironmentEnum } from './environmentEnum';
@@ -27,7 +27,16 @@ async function bootstrap() {
try {
// Convenience mode for the single-container Docker image.
// Hardened deployments should prefer an external Redis service.
exec(`redis-server --port ${process.env.REDIS_PORT || 6379}`);
const redisProcess = spawn(
'redis-server',
['--port', String(process.env.REDIS_PORT || 6379)],
{
stdio: 'ignore',
detached: true,
},
);
redisProcess.unref();
} catch (e) {
console.log('Unable to run redis server from app');
console.log(e);
@@ -40,7 +40,7 @@ export class PlaylistController {
return this.service.remove(id);
}
@Get('retry/:id')
@Post('retry/:id')
retryFailedOfPlaylist(
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
+1 -1
View File
@@ -34,7 +34,7 @@ export class AuthGuard implements CanActivate {
const providedToken = this.extractToken(request);
if (providedToken !== expectedToken) {
throw new UnauthorizedException('Invalid or missing Spooty auth token');
throw new UnauthorizedException('Invalid or missing Jarri Spooty auth token');
}
return true;
+16 -2
View File
@@ -22,7 +22,7 @@ export class UtilsService {
}
ensureInsideDownloadsRoot(candidatePath: string): string {
const root = this.getRootDownloadsPath();
const root = resolve(this.getRootDownloadsPath());
const resolvedCandidate = resolve(candidatePath);
const rel = relative(root, resolvedCandidate);
@@ -34,6 +34,20 @@ export class UtilsService {
}
stripFileIllegalChars(text: string): string {
return text.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'untitled';
const sanitized = String(text || '')
.replace(/[\x00-\x1f\x80-\x9f]/g, '-')
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\s+/g, ' ')
.replace(/[. ]+$/g, '')
.trim();
const fallback = sanitized || 'untitled';
const reservedName = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
if (reservedName.test(fallback) || fallback === '.' || fallback === '..') {
return `_${fallback}`;
}
return fallback.slice(0, 180);
}
}
+144 -12
View File
@@ -3,7 +3,6 @@ import { TrackEntity } from '../track/track.entity';
import { EnvironmentEnum } from '../environmentEnum';
import { TrackService } from '../track/track.service';
import { ConfigService } from '@nestjs/config';
import * as yts from 'yt-search';
import * as fs from 'fs';
import { spawn } from 'child_process';
const NodeID3 = require('node-id3');
@@ -63,6 +62,16 @@ interface CandidateScore extends YoutubeMatch {
rejected: boolean;
}
interface YtDlpSearchVideo {
url?: string;
webpage_url?: string;
id?: string;
title?: string;
uploader?: string;
channel?: string;
duration?: number;
}
@Injectable()
export class YoutubeService {
private readonly logger = new Logger(TrackService.name);
@@ -78,14 +87,14 @@ export class YoutubeService {
const query = `${artist} - ${name}`;
this.logger.debug(`Searching ${query} on YT`);
const result = await yts(query);
const videos = await this.searchYoutubeVideos(query);
const excludedVideoIds = new Set(
excludedUrls
.map((url) => this.getYoutubeVideoId(url))
.filter((id): id is string => !!id),
);
const candidates = (result.videos || [])
const candidates = videos
.filter((video: any) => !!video?.url && !!video?.title)
.filter((video: any) => {
const videoId = this.getYoutubeVideoId(String(video.url));
@@ -130,6 +139,55 @@ export class YoutubeService {
return accepted;
}
private async searchYoutubeVideos(query: string): Promise<YtDlpSearchVideo[]> {
const searchTarget = `ytsearch15:${query}`;
const args = [
'--dump-single-json',
'--flat-playlist',
'--skip-download',
'--no-playlist',
'--no-cache-dir',
'--no-cookies-from-browser',
'--add-header',
`User-Agent:${HEADERS['User-Agent']}`,
searchTarget,
];
const output = await this.runYtDlpForOutput(args);
let parsed: any;
try {
parsed = JSON.parse(output);
} catch (error) {
throw new Error(`Failed to parse yt-dlp search output: ${(error as Error).message}`);
}
const entries = Array.isArray(parsed?.entries) ? parsed.entries : [];
return entries
.filter((entry: YtDlpSearchVideo) => !!entry?.title && (!!entry?.url || !!entry?.webpage_url || !!entry?.id))
.map((entry: YtDlpSearchVideo) => ({
...entry,
url: this.normalizeYtDlpVideoUrl(entry),
author: entry.uploader || entry.channel || '',
seconds: typeof entry.duration === 'number' ? entry.duration : undefined,
}));
}
private normalizeYtDlpVideoUrl(video: YtDlpSearchVideo): string {
const rawUrl = String(video.webpage_url || video.url || '');
if (rawUrl.startsWith('http://') || rawUrl.startsWith('https://')) {
return rawUrl;
}
const id = String(video.id || rawUrl || '').trim();
if (!id) {
return '';
}
return `https://www.youtube.com/watch?v=${id}`;
}
private scoreCandidate(
video: any,
artist: string,
@@ -343,34 +401,69 @@ export class YoutubeService {
return tempCookiesFile;
}
private runYtDlp(args: string[]): Promise<void> {
private runYtDlpForOutput(args: string[]): Promise<string> {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn('yt-dlp', args, {
stdio: ['ignore', 'pipe', 'pipe'],
});
const timeoutMs = 10 * 60 * 1000;
let settled = false;
const settleResolve = (value: string) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
resolvePromise(value);
};
const settleReject = (error: Error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
rejectPromise(error);
};
const timeout = setTimeout(() => {
child.kill('SIGKILL');
settleReject(
new Error(`yt-dlp exceeded maximum runtime of ${timeoutMs}ms`),
);
}, timeoutMs);
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
child.stdout?.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', (error) => {
rejectPromise(new Error(`Failed to start yt-dlp: ${error.message}`));
settleReject(new Error(`Failed to start yt-dlp: ${error.message}`));
});
child.on('close', (code) => {
if (code === 0) {
resolvePromise();
if (settled) {
return;
}
rejectPromise(
if (code === 0) {
settleResolve(stdout);
return;
}
settleReject(
new Error(
`yt-dlp exited with code ${code}: ${this.classifyYtDlpError(stderr || stdout)}`,
),
@@ -379,6 +472,10 @@ export class YoutubeService {
});
}
private async runYtDlp(args: string[]): Promise<void> {
await this.runYtDlpForOutput(args);
}
async downloadAndFormat(track: TrackEntity, output: string): Promise<void> {
this.logger.debug(
`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`,
@@ -432,12 +529,47 @@ export class YoutubeService {
return;
}
const res = await fetch(coverUrl);
let parsedCoverUrl: URL;
try {
parsedCoverUrl = new URL(coverUrl);
} catch {
throw new Error('Invalid cover art URL');
}
if (!['http:', 'https:'].includes(parsedCoverUrl.protocol)) {
throw new Error('Rejected non-HTTP cover art URL');
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15_000);
let res: Response;
try {
res = await fetch(parsedCoverUrl.toString(), { signal: controller.signal });
} finally {
clearTimeout(timeout);
}
if (!res.ok) {
throw new Error(`Failed to fetch cover art: ${res.status}`);
}
const contentType = res.headers.get('content-type')?.split(';')[0].toLowerCase() || '';
if (!['image/jpeg', 'image/png', 'image/webp'].includes(contentType)) {
throw new Error(`Rejected unsupported cover art type: ${contentType || 'unknown'}`);
}
const maxCoverBytes = 5 * 1024 * 1024;
const contentLength = Number(res.headers.get('content-length') || 0);
if (contentLength > maxCoverBytes) {
throw new Error(`Rejected oversized cover art: ${contentLength} bytes`);
}
const arrayBuf = await res.arrayBuffer();
if (arrayBuf.byteLength > maxCoverBytes) {
throw new Error(`Rejected oversized cover art: ${arrayBuf.byteLength} bytes`);
}
const imageBuffer = Buffer.from(arrayBuf);
NodeID3.write(
@@ -445,7 +577,7 @@ export class YoutubeService {
title,
artist,
APIC: {
mime: 'image/jpeg',
mime: contentType,
type: { id: 3, name: 'front cover' },
description: 'cover',
imageBuffer,
+2 -1
View File
@@ -2,6 +2,7 @@ import {
Controller,
Delete,
Get,
Post,
NotFoundException,
Param,
ParseIntPipe,
@@ -66,7 +67,7 @@ export class TrackController {
return this.service.remove(id);
}
@Get('retry/:id')
@Post('retry/:id')
retry(
@Param('id', ParseIntPipe) id: number,
): Promise<void> {
+18 -2
View File
@@ -56,18 +56,34 @@ export class TrackService {
this.io.emit(WsTrackOperation.Delete, { id });
}
private toClientTrack(track: TrackEntity): Partial<TrackEntity> {
return {
id: track.id,
artist: track.artist,
name: track.name,
spotifyUrl: track.spotifyUrl,
youtubeUrl: track.youtubeUrl,
downloadAttemptCount: track.downloadAttemptCount,
status: track.status,
error: track.error,
coverUrl: track.coverUrl,
durationMs: track.durationMs,
createdAt: track.createdAt,
};
}
async create(track: TrackEntity, playlist?: PlaylistEntity): Promise<void> {
const savedTrack = await this.repository.save({ ...track, playlist });
await this.enqueueSearch(savedTrack.id);
this.io.emit(WsTrackOperation.New, {
track: savedTrack,
track: this.toClientTrack(savedTrack),
playlistId: playlist.id,
});
}
async update(id: number, track: TrackEntity): Promise<void> {
await this.repository.update(id, track);
this.io.emit(WsTrackOperation.Update, track);
this.io.emit(WsTrackOperation.Update, this.toClientTrack(track));
}
private parseRejectedYoutubeUrls(track: TrackEntity): string[] {
+1 -1
View File
@@ -1,4 +1,4 @@
# SpootyFe
# Jarri Spooty Frontend
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3.
+11 -12
View File
@@ -11,15 +11,14 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.0.6",
"@angular/common": "^19.0.6",
"@angular/compiler": "^19.0.6",
"@angular/core": "^19.0.6",
"@angular/forms": "^19.0.6",
"@angular/platform-browser": "^19.0.6",
"@angular/platform-browser-dynamic": "^19.0.6",
"@angular/router": "^19.0.6",
"@distube/ytdl-core": "^4.15.9",
"@angular/animations": "^19.2.22",
"@angular/common": "^19.2.22",
"@angular/compiler": "^19.2.22",
"@angular/core": "^19.2.22",
"@angular/forms": "^19.2.22",
"@angular/platform-browser": "^19.2.22",
"@angular/platform-browser-dynamic": "^19.2.22",
"@angular/router": "^19.2.22",
"@fortawesome/fontawesome-free": "^6.5.2",
"@ngneat/elf": "^2.5.1",
"@ngneat/elf-cli-ng": "^1.0.0",
@@ -36,9 +35,9 @@
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.0.7",
"@angular/cli": "^19.0.7",
"@angular/compiler-cli": "^19.0.6",
"@angular-devkit/build-angular": "^19.2.26",
"@angular/cli": "^19.2.26",
"@angular/compiler-cli": "^19.2.22",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.1.0",
"karma": "~6.4.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "SpootyFe",
"short_name": "SpootyFe",
"name": "Jarri Spooty",
"short_name": "Spooty",
"description": "Self-hosted Spotify downloader",
"icons": [
{
@@ -138,7 +138,7 @@ export class PlaylistService {
}
retryFailed(id: number): void {
this.http.get<void>(`${ENDPOINT}/retry/${id}`).subscribe();
this.http.post<void>(`${ENDPOINT}/retry/${id}`, {}).subscribe();
}
setActive(id: number, active: boolean): void {
@@ -57,7 +57,7 @@ export class TrackService {
}
retry(id: number): void {
this.http.get(`${ENDPOINT}/retry/${id}`).subscribe();
this.http.post(`${ENDPOINT}/retry/${id}`, {}).subscribe();
}
private initWsConnection(): void {
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<title>SpootyFe</title>
<title>Jarri Spooty</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">