@@ -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