Skip to content

Commit f6f9175

Browse files
committed
feat: update use aria2 and sse
1 parent 1fd256a commit f6f9175

6 files changed

Lines changed: 269 additions & 40 deletions

File tree

backend/bun.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/deploy.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ if ! command -v yt-dlp &> /dev/null; then
2424
fi
2525
fi
2626

27+
# Install aria2 if not available (required for YouTube Downloader)
28+
if ! command -v aria2c &> /dev/null; then
29+
echo "Installing aria2..."
30+
sudo apt install aria2
31+
fi
32+
2733
# Build the application
2834
bun install
2935
bun run build

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@nestjs/common": "^10.0.0",
2121
"@nestjs/config": "^3.0.0",
2222
"@nestjs/core": "^10.0.0",
23+
"@nestjs/event-emitter": "^3.0.1",
2324
"@nestjs/platform-express": "^10.0.0",
2425
"@nestjs/platform-socket.io": "^10.0.0",
2526
"@nestjs/schedule": "^4.0.0",

backend/src/youtube/youtube.controller.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,50 @@ export class YouTubeController {
7575
const downloadUrl = await this.youtubeService.getDownloadUrl(id);
7676
res.redirect(302, downloadUrl);
7777
}
78+
79+
/**
80+
* SSE endpoint for real-time progress updates
81+
*/
82+
@Get(':id/progress')
83+
async streamProgress(
84+
@Param('id') id: string,
85+
@Res() res: Response,
86+
): Promise<void> {
87+
// Set SSE headers
88+
res.setHeader('Content-Type', 'text/event-stream');
89+
res.setHeader('Cache-Control', 'no-cache');
90+
res.setHeader('Connection', 'keep-alive');
91+
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
92+
res.flushHeaders();
93+
94+
// Send initial status
95+
try {
96+
const initialStatus = await this.youtubeService.getDownloadStatus(id);
97+
res.write(`data: ${JSON.stringify(initialStatus)}\n\n`);
98+
} catch {
99+
res.write(`data: ${JSON.stringify({ error: 'Download not found' })}\n\n`);
100+
res.end();
101+
return;
102+
}
103+
104+
// Subscribe to progress stream
105+
const progressStream = this.youtubeService.getProgressStream(id);
106+
const subscription = progressStream.subscribe({
107+
next: (status) => {
108+
res.write(`data: ${JSON.stringify(status)}\n\n`);
109+
},
110+
complete: () => {
111+
res.end();
112+
},
113+
error: () => {
114+
res.end();
115+
},
116+
});
117+
118+
// Cleanup on client disconnect
119+
res.on('close', () => {
120+
subscription.unsubscribe();
121+
this.youtubeService.cleanupProgressSubject(id);
122+
});
123+
}
78124
}

backend/src/youtube/youtube.service.ts

Lines changed: 145 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Injectable, Logger, NotFoundException, OnModuleInit, BadRequestException } from '@nestjs/common';
2+
import { Subject } from 'rxjs';
23
import { DatabaseService } from '../database/database.service';
34
import { R2Service } from '../storage/r2.service';
45
import { execSync, spawn } from 'child_process';
@@ -61,6 +62,8 @@ export interface DownloadRequest {
6162
endTime?: string; // Optional: clip end time (seconds or mm:ss)
6263
embedSubtitles?: boolean; // Optional: embed subtitles into video
6364
subtitleLang?: string; // Optional: subtitle language code (en, vi, etc.)
65+
sponsorBlock?: boolean; // Optional: remove sponsor segments using SponsorBlock
66+
estimatedFilesize?: number; // Optional: estimated file size for auto aria2c
6467
}
6568

6669
export interface DownloadResult {
@@ -117,6 +120,9 @@ export class YouTubeService implements OnModuleInit {
117120
private activeDownloads = 0;
118121
private downloadQueue: QueuedDownload[] = [];
119122

123+
// SSE progress emitter
124+
private progressSubjects = new Map<string, Subject<DownloadResult>>();
125+
120126
constructor(
121127
private readonly databaseService: DatabaseService,
122128
private readonly r2Service: R2Service,
@@ -409,9 +415,14 @@ export class YouTubeService implements OnModuleInit {
409415
}
410416

411417
/**
412-
* Check if URL is a playlist
418+
* Check if URL is a playlist (exclude Radio/Mix, Liked, Watch Later)
413419
*/
414420
isPlaylistUrl(url: string): boolean {
421+
// Exclude special auto-generated lists:
422+
// RD = Radio/Mix, LL = Liked videos, WL = Watch Later
423+
if (/list=(RD|LL|WL)/.test(url)) {
424+
return false;
425+
}
415426
return url.includes('list=') || url.includes('/playlist');
416427
}
417428

@@ -436,6 +447,7 @@ export class YouTubeService implements OnModuleInit {
436447
const result = execSync(`${this.ytdlpPath} ${args.map(a => `"${a}"`).join(' ')}`, {
437448
encoding: 'utf-8',
438449
timeout: 60000,
450+
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large playlists
439451
});
440452

441453
const info = JSON.parse(result);
@@ -660,6 +672,7 @@ export class YouTubeService implements OnModuleInit {
660672
'--js-runtimes', 'bun',
661673
'--no-check-certificates',
662674
'--retries', '3',
675+
'--embed-metadata', // Embed video metadata (title, artist, description, etc.)
663676
];
664677

665678
// Add cookies if available
@@ -670,6 +683,7 @@ export class YouTubeService implements OnModuleInit {
670683
// Add format-specific options
671684
if (request.formatType === 'audio') {
672685
args.push('-x');
686+
args.push('--embed-thumbnail'); // Embed video thumbnail as album art
673687
// Audio format options
674688
switch (outputFormat) {
675689
case 'm4a':
@@ -716,6 +730,26 @@ export class YouTubeService implements OnModuleInit {
716730
this.logger.log(`📝 [${id}] Embedding subtitles: ${subLang}`);
717731
}
718732

733+
// Add SponsorBlock sponsor removal
734+
if (request.sponsorBlock) {
735+
args.push('--sponsorblock-remove', 'all');
736+
this.logger.log(`🚫 [${id}] SponsorBlock: removing sponsor segments`);
737+
}
738+
739+
// Auto-use aria2c for large files (>100MB) for faster downloads
740+
const ARIA2C_THRESHOLD = 100 * 1024 * 1024; // 100MB
741+
if (request.estimatedFilesize && request.estimatedFilesize > ARIA2C_THRESHOLD) {
742+
args.push(
743+
'--downloader', 'aria2c',
744+
// -x 16: 16 connections per server
745+
// -s 16: 16 connections total
746+
// -k 1M: min split size
747+
// --summary-interval=1: output progress every 1 second
748+
'--downloader-args', 'aria2c:-x 16 -s 16 -k 1M --summary-interval=1'
749+
);
750+
this.logger.log(`⚡ [${id}] Auto-enabling aria2c for large file (~${Math.round(request.estimatedFilesize / 1024 / 1024)}MB)`)
751+
}
752+
719753
args.push(request.url);
720754

721755
// Log the full command for debugging
@@ -741,11 +775,35 @@ export class YouTubeService implements OnModuleInit {
741775
this.logger.debug(`📥 [${id}] ${text.trim()}`);
742776
}
743777

744-
// Parse progress from yt-dlp output
745-
const progressMatch = text.match(/(\d+\.?\d*)%/);
746-
if (progressMatch) {
747-
const progress = Math.min(Math.floor(parseFloat(progressMatch[1])), 100);
748-
this.progressMap.set(id, progress);
778+
// Parse progress - handle both yt-dlp and aria2c formats
779+
// aria2c format: [#hash 12MiB/132MiB(9%) CN:16 DL:5.2MiB]
780+
// yt-dlp format: [download] 45.2% of 100MiB
781+
let progress = 0;
782+
783+
// Try aria2c format - get ALL matches and take highest (multi-line chunks)
784+
const aria2cMatches = text.matchAll(/\((\d+)%\)/g);
785+
for (const match of aria2cMatches) {
786+
const p = parseInt(match[1]);
787+
if (p > progress) progress = p;
788+
}
789+
790+
// Fallback to yt-dlp format if no aria2c matches
791+
if (progress === 0) {
792+
const progressMatch = text.match(/(\d+\.?\d*)%/);
793+
if (progressMatch) {
794+
progress = Math.floor(parseFloat(progressMatch[1]));
795+
}
796+
}
797+
798+
if (progress > 0) {
799+
progress = Math.min(progress, 100);
800+
const currentProgress = this.progressMap.get(id) || 0;
801+
// Only update if higher (avoid going backwards between video/audio)
802+
if (progress > currentProgress || progress === 100) {
803+
this.progressMap.set(id, progress);
804+
// Emit SSE event
805+
this.emitProgress(id);
806+
}
749807

750808
// Log every 25% progress
751809
if (progress >= lastLoggedProgress + 25) {
@@ -756,13 +814,39 @@ export class YouTubeService implements OnModuleInit {
756814
});
757815

758816
proc.stdout.on('data', (data) => {
759-
const text = data.toString().trim();
760-
this.logger.debug(`yt-dlp: ${text}`);
761-
// Also check stdout for progress
762-
const progressMatch = text.match(/(\d+\.?\d*)%/);
763-
if (progressMatch) {
764-
const progress = Math.min(Math.floor(parseFloat(progressMatch[1])), 100);
765-
this.progressMap.set(id, progress);
817+
const text = data.toString();
818+
this.logger.debug(`yt-dlp: ${text.trim()}`);
819+
820+
// Parse progress from stdout (aria2c outputs here)
821+
// Get ALL matches and take highest for multi-line chunks
822+
let progress = 0;
823+
const aria2cMatches = text.matchAll(/\((\d+)%\)/g);
824+
for (const match of aria2cMatches) {
825+
const p = parseInt(match[1]);
826+
if (p > progress) progress = p;
827+
}
828+
829+
// Fallback to yt-dlp format
830+
if (progress === 0) {
831+
const progressMatch = text.match(/(\d+\.?\d*)%/);
832+
if (progressMatch) {
833+
progress = Math.floor(parseFloat(progressMatch[1]));
834+
}
835+
}
836+
837+
if (progress > 0) {
838+
progress = Math.min(progress, 100);
839+
const currentProgress = this.progressMap.get(id) || 0;
840+
if (progress > currentProgress || progress === 100) {
841+
this.progressMap.set(id, progress);
842+
// Emit SSE event
843+
this.emitProgress(id);
844+
// Log progress from stdout
845+
if (progress >= lastLoggedProgress + 25) {
846+
this.logger.log(`📊 [${id}] Progress: ${progress}%`);
847+
lastLoggedProgress = progress;
848+
}
849+
}
766850
}
767851
});
768852

@@ -829,6 +913,8 @@ export class YouTubeService implements OnModuleInit {
829913
await this.databaseService.sql`
830914
UPDATE youtube_downloads SET status = 'uploading' WHERE id = ${id}
831915
`;
916+
// Emit SSE event for upload phase
917+
this.emitProgress(id);
832918
const fileSize = await this.r2Service.uploadObjectFromFile(objectKey, downloadedFile, contentType);
833919
this.logger.log(`☁️ [${id}] Upload complete!`);
834920

@@ -844,6 +930,8 @@ export class YouTubeService implements OnModuleInit {
844930
`;
845931

846932
this.logger.log(`🎉 [${id}] Download complete: ${displayFilename} (${(fileSize / 1024 / 1024).toFixed(2)} MB)`);
933+
// Emit SSE event for completed status
934+
this.emitProgress(id);
847935
// Cleanup progress map
848936
this.progressMap.delete(id);
849937
} catch (error) {
@@ -852,6 +940,8 @@ export class YouTubeService implements OnModuleInit {
852940
await this.databaseService.sql`
853941
UPDATE youtube_downloads SET status = 'failed', error = ${errorMessage} WHERE id = ${id}
854942
`;
943+
// Emit SSE event for failed status
944+
this.emitProgress(id);
855945
// Cleanup progress map
856946
this.progressMap.delete(id);
857947
} finally {
@@ -970,4 +1060,46 @@ export class YouTubeService implements OnModuleInit {
9701060
DELETE FROM youtube_downloads WHERE id = ${id}
9711061
`;
9721062
}
1063+
1064+
/**
1065+
* Get progress stream for SSE (Server-Sent Events)
1066+
*/
1067+
getProgressStream(id: string): Subject<DownloadResult> {
1068+
if (!this.progressSubjects.has(id)) {
1069+
this.progressSubjects.set(id, new Subject<DownloadResult>());
1070+
}
1071+
return this.progressSubjects.get(id)!;
1072+
}
1073+
1074+
/**
1075+
* Emit progress update to SSE subscribers
1076+
*/
1077+
private async emitProgress(id: string) {
1078+
const subject = this.progressSubjects.get(id);
1079+
if (subject) {
1080+
try {
1081+
const status = await this.getDownloadStatus(id);
1082+
subject.next(status);
1083+
1084+
// Complete and cleanup on terminal states
1085+
if (status.status === 'completed' || status.status === 'failed') {
1086+
subject.complete();
1087+
this.progressSubjects.delete(id);
1088+
}
1089+
} catch (error) {
1090+
// Ignore errors during emit
1091+
}
1092+
}
1093+
}
1094+
1095+
/**
1096+
* Cleanup progress subject
1097+
*/
1098+
cleanupProgressSubject(id: string) {
1099+
const subject = this.progressSubjects.get(id);
1100+
if (subject) {
1101+
subject.complete();
1102+
this.progressSubjects.delete(id);
1103+
}
1104+
}
9731105
}

0 commit comments

Comments
 (0)