Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a71a47f76a | ||
|
|
d83b0e6e1e | ||
|
|
1f3131d99a | ||
|
|
c3d2f39b82 | ||
|
|
09bbffa6e8 | ||
|
|
38ca4d6c8d | ||
|
|
0cd5294944 | ||
|
|
5d20972c2d | ||
|
|
5dc428b148 | ||
|
|
be11bb94d7 | ||
|
|
f64cc97475 | ||
|
|
005e47bf6e | ||
|
|
9e83001b73 |
+7
-1
@@ -1,6 +1,6 @@
|
|||||||
# Changelog
|
# 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.
|
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 isolated Spotify credential storage
|
||||||
- Added safer cookie handling flow
|
- Added safer cookie handling flow
|
||||||
- Improved Docker secret isolation documentation
|
- 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
|
### Frontend
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Spooty Hardened
|
# Jarri Spooty
|
||||||
|
|
||||||
Self-hosted Spotify playlist and track downloader built with NestJS + Angular.
|
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.
|
It retrieves metadata from Spotify and locates matching audio on YouTube.
|
||||||
|
|
||||||
This hardened branch focuses on:
|
This hardened branch focuses on:
|
||||||
@@ -21,6 +21,8 @@ This hardened branch focuses on:
|
|||||||
- Persistent SQLite state across container restarts
|
- Persistent SQLite state across container restarts
|
||||||
- Deterministic Docker config persistence
|
- Deterministic Docker config persistence
|
||||||
- Improved operational observability
|
- 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
|
- Automatic YouTube retry/fallback handling
|
||||||
- Failed YouTube candidate rejection memory
|
- Failed YouTube candidate rejection memory
|
||||||
- Deterministic yt-dlp error classification
|
- 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/downloads:/spooty/backend/downloads" \
|
||||||
-v "$PWD/spooty-config:/spooty/backend/config" \
|
-v "$PWD/spooty-config:/spooty/backend/config" \
|
||||||
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
|
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
|
||||||
spootyfy-hardened:local
|
jarri-spooty:local
|
||||||
```
|
```
|
||||||
|
|
||||||
Open:
|
Open:
|
||||||
@@ -158,8 +162,8 @@ Then:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
spooty:
|
spooty:
|
||||||
image: spootyfy-hardened:local
|
image: jarri-spooty:local
|
||||||
container_name: spooty
|
container_name: jarri-spooty
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
@@ -204,7 +208,7 @@ services:
|
|||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
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/downloads:/spooty/backend/downloads" \
|
||||||
-v "$PWD/spooty-config:/spooty/backend/config" \
|
-v "$PWD/spooty-config:/spooty/backend/config" \
|
||||||
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
|
-v "/etc/tokens/youtube.cookies.txt:/spooty/config/youtube.cookies.txt:ro" \
|
||||||
spootyfy-hardened:local
|
jarri-spooty:local
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+1833
-1982
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -17,8 +17,7 @@
|
|||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"changelog": "auto-changelog -p",
|
"changelog": "auto-changelog -p",
|
||||||
"release": "release-it",
|
"release": "release-it",
|
||||||
"commit": "cz",
|
"commit": "cz"
|
||||||
"check:lib": "npm-check-updates -w backend -f ytdlp-nodejs -u"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@release-it/bumper": "^7.0.1",
|
"@release-it/bumper": "^7.0.1",
|
||||||
|
|||||||
@@ -31,18 +31,13 @@
|
|||||||
"@nestjs/serve-static": "^4.0.2",
|
"@nestjs/serve-static": "^4.0.2",
|
||||||
"@nestjs/typeorm": "^10.0.2",
|
"@nestjs/typeorm": "^10.0.2",
|
||||||
"@nestjs/websockets": "^10.3.10",
|
"@nestjs/websockets": "^10.3.10",
|
||||||
"@types/fluent-ffmpeg": "^2.1.24",
|
|
||||||
"@types/yt-search": "^2.10.3",
|
|
||||||
"bullmq": "^5.31.2",
|
"bullmq": "^5.31.2",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
|
||||||
"isomorphic-unfetch": "^4.0.2",
|
"isomorphic-unfetch": "^4.0.2",
|
||||||
"node-id3": "^0.2.9",
|
"node-id3": "^0.2.9",
|
||||||
"reflect-metadata": "^0.2.0",
|
"reflect-metadata": "^0.2.0",
|
||||||
"rxjs": "7.8.0",
|
"rxjs": "7.8.0",
|
||||||
"spotify-url-info": "^3.2.18",
|
"spotify-url-info": "^3.2.18",
|
||||||
"sqlite3": "^5.1.7",
|
"sqlite3": "^5.1.7"
|
||||||
"yt-search": "^2.12.1",
|
|
||||||
"ytdlp-nodejs": "^3.4.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.0.0",
|
"@nestjs/cli": "^10.0.0",
|
||||||
|
|||||||
+11
-2
@@ -4,7 +4,7 @@ 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 { spawn } from 'child_process';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { EnvironmentEnum } from './environmentEnum';
|
import { EnvironmentEnum } from './environmentEnum';
|
||||||
|
|
||||||
@@ -27,7 +27,16 @@ async function bootstrap() {
|
|||||||
try {
|
try {
|
||||||
// Convenience mode for the single-container Docker image.
|
// Convenience mode for the single-container Docker image.
|
||||||
// Hardened deployments should prefer an external Redis service.
|
// 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) {
|
} catch (e) {
|
||||||
console.log('Unable to run redis server from app');
|
console.log('Unable to run redis server from app');
|
||||||
console.log(e);
|
console.log(e);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export class PlaylistController {
|
|||||||
return this.service.remove(id);
|
return this.service.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('retry/:id')
|
@Post('retry/:id')
|
||||||
retryFailedOfPlaylist(
|
retryFailedOfPlaylist(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export class AuthGuard implements CanActivate {
|
|||||||
const providedToken = this.extractToken(request);
|
const providedToken = this.extractToken(request);
|
||||||
|
|
||||||
if (providedToken !== expectedToken) {
|
if (providedToken !== expectedToken) {
|
||||||
throw new UnauthorizedException('Invalid or missing Spooty auth token');
|
throw new UnauthorizedException('Invalid or missing Jarri Spooty auth token');
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export class UtilsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ensureInsideDownloadsRoot(candidatePath: string): string {
|
ensureInsideDownloadsRoot(candidatePath: string): string {
|
||||||
const root = this.getRootDownloadsPath();
|
const root = resolve(this.getRootDownloadsPath());
|
||||||
const resolvedCandidate = resolve(candidatePath);
|
const resolvedCandidate = resolve(candidatePath);
|
||||||
const rel = relative(root, resolvedCandidate);
|
const rel = relative(root, resolvedCandidate);
|
||||||
|
|
||||||
@@ -34,6 +34,20 @@ export class UtilsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stripFileIllegalChars(text: string): string {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { TrackEntity } from '../track/track.entity';
|
|||||||
import { EnvironmentEnum } from '../environmentEnum';
|
import { EnvironmentEnum } from '../environmentEnum';
|
||||||
import { TrackService } from '../track/track.service';
|
import { TrackService } from '../track/track.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as yts from 'yt-search';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
const NodeID3 = require('node-id3');
|
const NodeID3 = require('node-id3');
|
||||||
@@ -63,6 +62,16 @@ interface CandidateScore extends YoutubeMatch {
|
|||||||
rejected: boolean;
|
rejected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface YtDlpSearchVideo {
|
||||||
|
url?: string;
|
||||||
|
webpage_url?: string;
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
uploader?: string;
|
||||||
|
channel?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class YoutubeService {
|
export class YoutubeService {
|
||||||
private readonly logger = new Logger(TrackService.name);
|
private readonly logger = new Logger(TrackService.name);
|
||||||
@@ -78,14 +87,14 @@ export class YoutubeService {
|
|||||||
const query = `${artist} - ${name}`;
|
const query = `${artist} - ${name}`;
|
||||||
this.logger.debug(`Searching ${query} on YT`);
|
this.logger.debug(`Searching ${query} on YT`);
|
||||||
|
|
||||||
const result = await yts(query);
|
const videos = await this.searchYoutubeVideos(query);
|
||||||
const excludedVideoIds = new Set(
|
const excludedVideoIds = new Set(
|
||||||
excludedUrls
|
excludedUrls
|
||||||
.map((url) => this.getYoutubeVideoId(url))
|
.map((url) => this.getYoutubeVideoId(url))
|
||||||
.filter((id): id is string => !!id),
|
.filter((id): id is string => !!id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const candidates = (result.videos || [])
|
const candidates = videos
|
||||||
.filter((video: any) => !!video?.url && !!video?.title)
|
.filter((video: any) => !!video?.url && !!video?.title)
|
||||||
.filter((video: any) => {
|
.filter((video: any) => {
|
||||||
const videoId = this.getYoutubeVideoId(String(video.url));
|
const videoId = this.getYoutubeVideoId(String(video.url));
|
||||||
@@ -130,6 +139,55 @@ export class YoutubeService {
|
|||||||
return accepted;
|
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(
|
private scoreCandidate(
|
||||||
video: any,
|
video: any,
|
||||||
artist: string,
|
artist: string,
|
||||||
@@ -343,34 +401,69 @@ export class YoutubeService {
|
|||||||
return tempCookiesFile;
|
return tempCookiesFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
private runYtDlp(args: string[]): Promise<void> {
|
private runYtDlpForOutput(args: string[]): Promise<string> {
|
||||||
return new Promise((resolvePromise, rejectPromise) => {
|
return new Promise((resolvePromise, rejectPromise) => {
|
||||||
const child = spawn('yt-dlp', args, {
|
const child = spawn('yt-dlp', args, {
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
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 stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
|
||||||
child.stdout.on('data', (chunk) => {
|
child.stdout?.on('data', (chunk) => {
|
||||||
stdout += chunk.toString();
|
stdout += chunk.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stderr.on('data', (chunk) => {
|
child.stderr?.on('data', (chunk) => {
|
||||||
stderr += chunk.toString();
|
stderr += chunk.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('error', (error) => {
|
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) => {
|
child.on('close', (code) => {
|
||||||
if (code === 0) {
|
if (settled) {
|
||||||
resolvePromise();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
rejectPromise(
|
if (code === 0) {
|
||||||
|
settleResolve(stdout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settleReject(
|
||||||
new Error(
|
new Error(
|
||||||
`yt-dlp exited with code ${code}: ${this.classifyYtDlpError(stderr || stdout)}`,
|
`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> {
|
async downloadAndFormat(track: TrackEntity, output: string): Promise<void> {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`,
|
`Downloading ${track.artist} - ${track.name} (${track.youtubeUrl}) from YT`,
|
||||||
@@ -432,12 +529,47 @@ export class YoutubeService {
|
|||||||
return;
|
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) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to fetch cover art: ${res.status}`);
|
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();
|
const arrayBuf = await res.arrayBuffer();
|
||||||
|
if (arrayBuf.byteLength > maxCoverBytes) {
|
||||||
|
throw new Error(`Rejected oversized cover art: ${arrayBuf.byteLength} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
const imageBuffer = Buffer.from(arrayBuf);
|
const imageBuffer = Buffer.from(arrayBuf);
|
||||||
|
|
||||||
NodeID3.write(
|
NodeID3.write(
|
||||||
@@ -445,7 +577,7 @@ export class YoutubeService {
|
|||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
APIC: {
|
APIC: {
|
||||||
mime: 'image/jpeg',
|
mime: contentType,
|
||||||
type: { id: 3, name: 'front cover' },
|
type: { id: 3, name: 'front cover' },
|
||||||
description: 'cover',
|
description: 'cover',
|
||||||
imageBuffer,
|
imageBuffer,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Post,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
@@ -66,7 +67,7 @@ export class TrackController {
|
|||||||
return this.service.remove(id);
|
return this.service.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('retry/:id')
|
@Post('retry/:id')
|
||||||
retry(
|
retry(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|||||||
@@ -56,18 +56,34 @@ export class TrackService {
|
|||||||
this.io.emit(WsTrackOperation.Delete, { id });
|
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> {
|
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.enqueueSearch(savedTrack.id);
|
await this.enqueueSearch(savedTrack.id);
|
||||||
this.io.emit(WsTrackOperation.New, {
|
this.io.emit(WsTrackOperation.New, {
|
||||||
track: savedTrack,
|
track: this.toClientTrack(savedTrack),
|
||||||
playlistId: playlist.id,
|
playlistId: playlist.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, track: TrackEntity): Promise<void> {
|
async update(id: number, track: TrackEntity): Promise<void> {
|
||||||
await this.repository.update(id, track);
|
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[] {
|
private parseRejectedYoutubeUrls(track: TrackEntity): string[] {
|
||||||
|
|||||||
@@ -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.
|
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 18.0.3.
|
||||||
|
|
||||||
|
|||||||
+11
-12
@@ -11,15 +11,14 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^19.0.6",
|
"@angular/animations": "^19.2.22",
|
||||||
"@angular/common": "^19.0.6",
|
"@angular/common": "^19.2.22",
|
||||||
"@angular/compiler": "^19.0.6",
|
"@angular/compiler": "^19.2.22",
|
||||||
"@angular/core": "^19.0.6",
|
"@angular/core": "^19.2.22",
|
||||||
"@angular/forms": "^19.0.6",
|
"@angular/forms": "^19.2.22",
|
||||||
"@angular/platform-browser": "^19.0.6",
|
"@angular/platform-browser": "^19.2.22",
|
||||||
"@angular/platform-browser-dynamic": "^19.0.6",
|
"@angular/platform-browser-dynamic": "^19.2.22",
|
||||||
"@angular/router": "^19.0.6",
|
"@angular/router": "^19.2.22",
|
||||||
"@distube/ytdl-core": "^4.15.9",
|
|
||||||
"@fortawesome/fontawesome-free": "^6.5.2",
|
"@fortawesome/fontawesome-free": "^6.5.2",
|
||||||
"@ngneat/elf": "^2.5.1",
|
"@ngneat/elf": "^2.5.1",
|
||||||
"@ngneat/elf-cli-ng": "^1.0.0",
|
"@ngneat/elf-cli-ng": "^1.0.0",
|
||||||
@@ -36,9 +35,9 @@
|
|||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.15.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^19.0.7",
|
"@angular-devkit/build-angular": "^19.2.26",
|
||||||
"@angular/cli": "^19.0.7",
|
"@angular/cli": "^19.2.26",
|
||||||
"@angular/compiler-cli": "^19.0.6",
|
"@angular/compiler-cli": "^19.2.22",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
"jasmine-core": "~5.1.0",
|
"jasmine-core": "~5.1.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "SpootyFe",
|
"name": "Jarri Spooty",
|
||||||
"short_name": "SpootyFe",
|
"short_name": "Spooty",
|
||||||
"description": "Self-hosted Spotify downloader",
|
"description": "Self-hosted Spotify downloader",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export class PlaylistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
retryFailed(id: number): void {
|
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 {
|
setActive(id: number, active: boolean): void {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export class TrackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
retry(id: number): void {
|
retry(id: number): void {
|
||||||
this.http.get(`${ENDPOINT}/retry/${id}`).subscribe();
|
this.http.post(`${ENDPOINT}/retry/${id}`, {}).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
private initWsConnection(): void {
|
private initWsConnection(): void {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>SpootyFe</title>
|
<title>Jarri Spooty</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
|||||||
Reference in New Issue
Block a user