Skip to content

Commit d73b867

Browse files
author
minhnq
committed
feat: add youtube downloader
1 parent 08b365e commit d73b867

18 files changed

Lines changed: 1557 additions & 7 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Free online developer tools. No signup required, 100% client-side processing for
1717
| **File Transfer** | Share any file with expiring download links | S3/R2 |
1818
| **Time Capsule** | Lock files until a future date | S3/R2 |
1919
| **Speech to Text** | Transcribe audio with word-level timestamps and seek | Server (Deepgram) |
20+
| **YouTube Downloader** | Download YouTube videos and audio in various qualities | Server (ytdl-core) |
2021
| **ANeko Builder** | Create custom skins for ANeko Reborn Android app | Client-side |
2122
| **Anonymous Chat** | Real-time public chat with random usernames | PostgreSQL |
2223

backend/bun.lock

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

backend/deploy.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,18 @@
22
export BUN_INSTALL="$HOME/.bun"
33
export PATH="$BUN_INSTALL/bin:$PATH"
44

5+
# Install bun if not available
56
if ! command -v bun &> /dev/null; then
67
curl -fsSL https://bun.sh/install | bash
78
fi
89

10+
# Install yt-dlp if not available (required for YouTube Downloader)
11+
if ! command -v yt-dlp &> /dev/null; then
12+
echo "Installing yt-dlp..."
13+
pip3 install --user yt-dlp || pip install --user yt-dlp
14+
export PATH="$HOME/.local/bin:$PATH"
15+
fi
16+
917
# Build the application
1018
bun install
1119
bun run build

backend/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"dependencies": {
1717
"@aws-sdk/client-s3": "^3.700.0",
1818
"@deepgram/sdk": "^4.11.3",
19+
"@distube/ytdl-core": "^4.16.12",
1920
"@nestjs/common": "^10.0.0",
2021
"@nestjs/config": "^3.0.0",
2122
"@nestjs/core": "^10.0.0",
@@ -26,7 +27,8 @@
2627
"postgres": "^3.4.5",
2728
"reflect-metadata": "^0.1.13",
2829
"rxjs": "^7.8.1",
29-
"socket.io": "^4.7.0"
30+
"socket.io": "^4.7.0",
31+
"yt-dlp-exec": "^1.0.2"
3032
},
3133
"devDependencies": {
3234
"@nestjs/cli": "^10.0.0",

backend/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { StorageModule } from './storage/storage.module';
66
import { CleanupModule } from './cleanup/cleanup.module';
77
import { ChatModule } from './chat/chat.module';
88
import { SpeechModule } from './speech/speech.module';
9+
import { YouTubeModule } from './youtube/youtube.module';
910

1011
@Module({
1112
imports: [
@@ -19,6 +20,7 @@ import { SpeechModule } from './speech/speech.module';
1920
CleanupModule,
2021
ChatModule,
2122
SpeechModule,
23+
YouTubeModule,
2224
],
2325
})
2426
export class AppModule { }

backend/src/cleanup/cleanup.service.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,5 +117,42 @@ export class CleanupService {
117117
this.logger.error('❌ Speech cleanup failed:', error);
118118
}
119119
}
120+
121+
/**
122+
* Cleanup expired YouTube downloads - runs every hour
123+
*/
124+
@Cron(CronExpression.EVERY_HOUR)
125+
async handleYouTubeCleanup() {
126+
this.logger.log('🧹 Starting YouTube downloads cleanup...');
127+
128+
try {
129+
const expiredDownloads = await this.databaseService.getExpiredYouTubeDownloads();
130+
131+
if (expiredDownloads.length === 0) {
132+
this.logger.log('✅ No expired YouTube downloads found');
133+
return;
134+
}
135+
136+
let deleted = 0;
137+
let failed = 0;
138+
139+
for (const download of expiredDownloads) {
140+
try {
141+
if (download.object_key) {
142+
await this.r2Service.deleteObject(download.object_key);
143+
}
144+
await this.databaseService.deleteYouTubeDownload(download.id);
145+
deleted++;
146+
} catch (error) {
147+
this.logger.error(`Failed to delete YouTube download ${download.id}:`, error);
148+
failed++;
149+
}
150+
}
151+
152+
this.logger.log(`✅ YouTube cleanup complete: ${deleted} deleted, ${failed} failed`);
153+
} catch (error) {
154+
this.logger.error('❌ YouTube cleanup failed:', error);
155+
}
156+
}
120157
}
121158

backend/src/database/database.service.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,5 +202,25 @@ export class DatabaseService implements OnModuleInit {
202202
async deleteSpeechTranscription(id: string) {
203203
return this.sql`DELETE FROM speech_transcriptions WHERE id = ${id}`;
204204
}
205+
206+
// ============ YouTube Downloads ============
207+
208+
/**
209+
* Get expired YouTube downloads
210+
*/
211+
async getExpiredYouTubeDownloads() {
212+
return this.sql`
213+
SELECT id, object_key
214+
FROM youtube_downloads
215+
WHERE expires_at < NOW()
216+
`;
217+
}
218+
219+
/**
220+
* Delete a YouTube download by ID
221+
*/
222+
async deleteYouTubeDownload(id: string) {
223+
return this.sql`DELETE FROM youtube_downloads WHERE id = ${id}`;
224+
}
205225
}
206226

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {
2+
Controller,
3+
Get,
4+
Post,
5+
Query,
6+
Body,
7+
Param,
8+
Res,
9+
Headers,
10+
BadRequestException,
11+
} from '@nestjs/common';
12+
import { Response } from 'express';
13+
import { DownloadRequest, DownloadResult, VideoInfo, YouTubeService } from './youtube.service';
14+
15+
16+
@Controller('youtube')
17+
export class YouTubeController {
18+
constructor(private readonly youtubeService: YouTubeService) { }
19+
20+
/**
21+
* Get video information from YouTube URL
22+
*/
23+
@Get('info')
24+
async getVideoInfo(@Query('url') url: string): Promise<VideoInfo> {
25+
if (!url) {
26+
throw new BadRequestException('URL is required');
27+
}
28+
return this.youtubeService.getVideoInfo(url);
29+
}
30+
31+
/**
32+
* Start download with selected format
33+
*/
34+
@Post('download')
35+
async startDownload(@Body() request: DownloadRequest): Promise<DownloadResult> {
36+
if (!request.url) {
37+
throw new BadRequestException('URL is required');
38+
}
39+
if (!request.formatType || !['video', 'audio'].includes(request.formatType)) {
40+
throw new BadRequestException('formatType must be "video" or "audio"');
41+
}
42+
if (!request.quality) {
43+
throw new BadRequestException('quality is required');
44+
}
45+
return this.youtubeService.startDownload(request);
46+
}
47+
48+
/**
49+
* Get download status
50+
*/
51+
@Get(':id')
52+
async getDownloadStatus(@Param('id') id: string): Promise<DownloadResult> {
53+
return this.youtubeService.getDownloadStatus(id);
54+
}
55+
56+
/**
57+
* Stream downloaded file with Range support
58+
*/
59+
@Get(':id/file')
60+
async streamFile(
61+
@Param('id') id: string,
62+
@Headers('range') range: string | undefined,
63+
@Res() res: Response,
64+
): Promise<void> {
65+
const { buffer, contentType, filename } = await this.youtubeService.getDownloadedFile(id);
66+
const fileSize = buffer.length;
67+
68+
// Set filename for download
69+
const encodedFilename = encodeURIComponent(filename);
70+
71+
if (range) {
72+
const parts = range.replace(/bytes=/, '').split('-');
73+
const start = parseInt(parts[0], 10);
74+
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
75+
const chunkSize = end - start + 1;
76+
77+
res.status(206);
78+
res.set({
79+
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
80+
'Accept-Ranges': 'bytes',
81+
'Content-Length': chunkSize,
82+
'Content-Type': contentType,
83+
'Content-Disposition': `attachment; filename*=UTF-8''${encodedFilename}`,
84+
});
85+
res.end(buffer.subarray(start, end + 1));
86+
} else {
87+
res.set({
88+
'Accept-Ranges': 'bytes',
89+
'Content-Length': fileSize,
90+
'Content-Type': contentType,
91+
'Content-Disposition': `attachment; filename*=UTF-8''${encodedFilename}`,
92+
});
93+
res.end(buffer);
94+
}
95+
}
96+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from '@nestjs/common';
2+
import { DatabaseModule } from '../database/database.module';
3+
import { StorageModule } from '../storage/storage.module';
4+
import { YouTubeController } from './youtube.controller';
5+
import { YouTubeService } from './youtube.service';
6+
7+
@Module({
8+
imports: [DatabaseModule, StorageModule],
9+
controllers: [YouTubeController],
10+
providers: [YouTubeService],
11+
exports: [YouTubeService],
12+
})
13+
export class YouTubeModule { }

0 commit comments

Comments
 (0)