11import { Injectable , Logger , NotFoundException , OnModuleInit , BadRequestException } from '@nestjs/common' ;
2+ import { Subject } from 'rxjs' ;
23import { DatabaseService } from '../database/database.service' ;
34import { R2Service } from '../storage/r2.service' ;
45import { 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
6669export 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 ( / l i s t = ( R D | L L | W L ) / . 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