Skip to content

Commit 1b2cc35

Browse files
committed
feat: improve r2 logic to ensure have enough storage
1 parent 7c2cf0b commit 1b2cc35

1 file changed

Lines changed: 152 additions & 0 deletions

File tree

backend/src/youtube/youtube.service.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ export class YouTubeService implements OnModuleInit {
121121
private activeDownloads = 0;
122122
private downloadQueue: QueuedDownload[] = [];
123123

124+
// Storage quota management
125+
private readonly STORAGE_QUOTA = 10 * 1024 * 1024 * 1024; // 10GB
126+
private readonly CLEANUP_THRESHOLD = 0.9; // Start cleanup at 90% (9GB)
127+
124128
// SSE progress emitter
125129
private progressSubjects = new Map<string, Subject<DownloadResult>>();
126130

@@ -492,6 +496,37 @@ export class YouTubeService implements OnModuleInit {
492496
const id = this.generateId();
493497
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
494498
const videoId = this.extractVideoId(request.url) || 'unknown';
499+
const isClipMode = !!(request.startTime || request.endTime);
500+
501+
// Check for existing download (deduplication) - only for full video, not clips
502+
if (!isClipMode) {
503+
const existingDownload = await this.findExistingDownload(
504+
videoId,
505+
request.formatType,
506+
request.quality,
507+
request.outputFormat || (request.formatType === 'video' ? 'mp4' : 'mp3')
508+
);
509+
if (existingDownload) {
510+
// Reset expiry to 1 hour from now and return existing download
511+
const existingId = existingDownload.id as string;
512+
await this.databaseService.sql`
513+
UPDATE youtube_downloads
514+
SET expires_at = ${expiresAt}
515+
WHERE id = ${existingId}
516+
`;
517+
this.logger.log(`♻️ Reusing existing download: ${existingDownload.title} (reset expiry)`);
518+
return {
519+
id: existingId,
520+
videoId: existingDownload.video_id as string,
521+
title: existingDownload.title as string,
522+
status: 'completed' as const,
523+
progress: 100,
524+
downloadUrl: `/youtube/${existingId}/file`,
525+
fileSize: existingDownload.file_size ? parseInt(existingDownload.file_size as string) : undefined,
526+
filename: existingDownload.filename as string | undefined,
527+
};
528+
}
529+
}
495530

496531
try {
497532
// Get video info first (includes actual filesizes from yt-dlp)
@@ -982,6 +1017,11 @@ export class YouTubeService implements OnModuleInit {
9821017
};
9831018
const contentType = contentTypeMap[ext] || (request.formatType === 'video' ? 'video/mp4' : 'audio/mpeg');
9841019

1020+
// Check storage quota and cleanup if needed before upload
1021+
const tempFileStats = fs.statSync(downloadedFile);
1022+
const tempFileSize = tempFileStats.size;
1023+
await this.cleanupStorageIfNeeded(tempFileSize);
1024+
9851025
this.logger.log(`☁️ [${id}] Uploading to R2 (streaming)...`);
9861026
// Set progress > 100 to indicate uploading phase (101-200 = uploading 0-99%)
9871027
this.progressMap.set(id, 101);
@@ -1118,6 +1158,37 @@ export class YouTubeService implements OnModuleInit {
11181158
};
11191159
}
11201160

1161+
/**
1162+
* Find existing completed download for deduplication
1163+
* @param videoId - YouTube video ID
1164+
* @param formatType - 'video' or 'audio'
1165+
* @param quality - Quality string (e.g. '1080p', '720p')
1166+
* @param outputFormat - Output format (e.g. 'mp4', 'mp3')
1167+
* @returns Existing download record if found and not expired
1168+
*/
1169+
private async findExistingDownload(
1170+
videoId: string,
1171+
formatType: string,
1172+
quality: string,
1173+
outputFormat: string
1174+
): Promise<Record<string, unknown> | null> {
1175+
// Find completed downloads with same video_id, format_type, quality
1176+
// and filename ending with the expected extension
1177+
const records = await this.databaseService.sql`
1178+
SELECT * FROM youtube_downloads
1179+
WHERE video_id = ${videoId}
1180+
AND format_type = ${formatType}
1181+
AND quality = ${quality}
1182+
AND status = 'completed'
1183+
AND object_key IS NOT NULL
1184+
AND expires_at > NOW()
1185+
AND filename LIKE ${'%.' + outputFormat}
1186+
ORDER BY created_at DESC
1187+
LIMIT 1
1188+
`;
1189+
return records.length > 0 ? records[0] : null;
1190+
}
1191+
11211192
/**
11221193
* Get presigned download URL for direct R2 download
11231194
*/
@@ -1187,6 +1258,87 @@ export class YouTubeService implements OnModuleInit {
11871258
}
11881259
}
11891260

1261+
/**
1262+
* Get total storage used by YouTube downloads (in bytes)
1263+
*/
1264+
async getTotalStorageUsed(): Promise<number> {
1265+
const result = await this.databaseService.sql`
1266+
SELECT COALESCE(SUM(file_size), 0) as total_size
1267+
FROM youtube_downloads
1268+
WHERE status = 'completed' AND object_key IS NOT NULL
1269+
`;
1270+
return parseInt(result[0]?.total_size || '0');
1271+
}
1272+
1273+
/**
1274+
* Cleanup old downloads to make space before upload
1275+
* Strategy: Delete expired first, then oldest completed (FIFO)
1276+
* @param requiredSpace - Minimum bytes needed for new upload
1277+
*/
1278+
async cleanupStorageIfNeeded(requiredSpace: number): Promise<void> {
1279+
const currentStorage = await this.getTotalStorageUsed();
1280+
const projectedStorage = currentStorage + requiredSpace;
1281+
const threshold = this.STORAGE_QUOTA * this.CLEANUP_THRESHOLD;
1282+
1283+
if (projectedStorage <= threshold) {
1284+
this.logger.debug(`📊 Storage OK: ${(currentStorage / 1024 / 1024 / 1024).toFixed(2)}GB / ${(this.STORAGE_QUOTA / 1024 / 1024 / 1024).toFixed(0)}GB`);
1285+
return;
1286+
}
1287+
1288+
this.logger.log(`⚠️ Storage near limit: ${(currentStorage / 1024 / 1024 / 1024).toFixed(2)}GB. Cleaning up...`);
1289+
1290+
// Calculate how much space we need to free
1291+
const spaceToFree = projectedStorage - threshold + requiredSpace;
1292+
let freedSpace = 0;
1293+
let deletedCount = 0;
1294+
1295+
// Step 1: Delete expired downloads first
1296+
const expiredDownloads = await this.databaseService.sql`
1297+
SELECT id, object_key, file_size
1298+
FROM youtube_downloads
1299+
WHERE expires_at < NOW() AND object_key IS NOT NULL
1300+
ORDER BY expires_at ASC
1301+
`;
1302+
1303+
for (const download of expiredDownloads) {
1304+
if (freedSpace >= spaceToFree) break;
1305+
try {
1306+
await this.deleteDownload(download.id, download.object_key);
1307+
freedSpace += parseInt(download.file_size || '0');
1308+
deletedCount++;
1309+
} catch (error) {
1310+
this.logger.warn(`Failed to delete expired download ${download.id}: ${error}`);
1311+
}
1312+
}
1313+
1314+
// Step 2: If still need space, delete oldest completed downloads (FIFO)
1315+
if (freedSpace < spaceToFree) {
1316+
const oldestDownloads = await this.databaseService.sql`
1317+
SELECT id, object_key, file_size, title
1318+
FROM youtube_downloads
1319+
WHERE status = 'completed' AND object_key IS NOT NULL
1320+
ORDER BY created_at ASC
1321+
LIMIT 20
1322+
`;
1323+
1324+
for (const download of oldestDownloads) {
1325+
if (freedSpace >= spaceToFree) break;
1326+
try {
1327+
await this.deleteDownload(download.id, download.object_key);
1328+
freedSpace += parseInt(download.file_size || '0');
1329+
deletedCount++;
1330+
this.logger.log(`🗑️ Deleted old download: ${download.title?.substring(0, 30)}...`);
1331+
} catch (error) {
1332+
this.logger.warn(`Failed to delete download ${download.id}: ${error}`);
1333+
}
1334+
}
1335+
}
1336+
1337+
if (deletedCount > 0) {
1338+
this.logger.log(`✅ Storage cleanup: deleted ${deletedCount} downloads, freed ${(freedSpace / 1024 / 1024).toFixed(1)}MB`);
1339+
}
1340+
}
1341+
11901342
/**
11911343
* Cleanup progress subject
11921344
*/

0 commit comments

Comments
 (0)