@@ -60,6 +60,7 @@ export interface DownloadRequest {
6060 outputFormat ?: string ; // video: mp4, webm, mkv | audio: mp3, m4a, opus, flac, wav
6161 startTime ?: string ; // Optional: clip start time (seconds or mm:ss)
6262 endTime ?: string ; // Optional: clip end time (seconds or mm:ss)
63+ clipTitle ?: string ; // Optional: chapter/clip title to use as filename prefix
6364 embedSubtitles ?: boolean ; // Optional: embed subtitles into video
6465 subtitleLang ?: string ; // Optional: subtitle language code (en, vi, etc.)
6566 sponsorBlock ?: boolean ; // Optional: remove sponsor segments using SponsorBlock
@@ -708,13 +709,21 @@ export class YouTubeService implements OnModuleInit {
708709 args . push ( '--merge-output-format' , outputFormat ) ;
709710 }
710711
712+ // Track if we're in clip mode (for disabling aria2c later)
713+ const isClipMode = ! ! ( request . startTime || request . endTime ) ;
714+
711715 // Add clip download using --download-sections
712716 // Note: For long videos this will be slow as it streams from start
713- if ( request . startTime || request . endTime ) {
717+ if ( isClipMode ) {
714718 const start = request . startTime || '0' ;
715719 const end = request . endTime || 'inf' ;
716720 args . push ( '--download-sections' , `*${ start } -${ end } ` ) ;
717- this . logger . log ( `✂️ [${ id } ] Clip mode: ${ start } to ${ end } ` ) ;
721+ // CRITICAL: Force keyframes at cut points to ensure accurate clipping
722+ // Without this, video duration may be wrong and have black frames at the end
723+ args . push ( '--force-keyframes-at-cuts' ) ;
724+ // Prevent parallel stream issues that cause black frames
725+ args . push ( '--compat-option' , 'no-direct-merge' ) ;
726+ this . logger . log ( `✂️ [${ id } ] Clip mode: ${ start } to ${ end } (with keyframe re-encoding)` ) ;
718727 }
719728
720729 // Add subtitle embedding (video only)
@@ -737,8 +746,9 @@ export class YouTubeService implements OnModuleInit {
737746 }
738747
739748 // Auto-use aria2c for large files (>100MB) for faster downloads
749+ // BUT NOT for clip mode - aria2c conflicts with --download-sections causing black frames
740750 const ARIA2C_THRESHOLD = 100 * 1024 * 1024 ; // 100MB
741- if ( request . estimatedFilesize && request . estimatedFilesize > ARIA2C_THRESHOLD ) {
751+ if ( request . estimatedFilesize && request . estimatedFilesize > ARIA2C_THRESHOLD && ! isClipMode ) {
742752 args . push (
743753 '--downloader' , 'aria2c' ,
744754 // -x 16: 16 connections per server
@@ -917,10 +927,44 @@ export class YouTubeService implements OnModuleInit {
917927 this . logger . log ( `📁 [${ id } ] Found file at expected path` ) ;
918928 }
919929
930+ // For clip mode: remux to fix container metadata duration
931+ // The clipped file may contain extra streams (like subtitle/data) with original video duration
932+ // QuickTime reads duration from the longest stream, causing wrong display
933+ // We remux with explicit stream mapping to strip extra streams and fix metadata
934+ if ( isClipMode && request . formatType === 'video' ) {
935+ const fixedFile = path . join ( tmpDir , `yt_${ id } _fixed.${ ext } ` ) ;
936+ this . logger . log ( `🔧 [${ id } ] Fixing clip metadata with ffmpeg remux...` ) ;
937+ try {
938+ execSync (
939+ // Map only video (0:v:0) and audio (0:a:0) streams, strip everything else
940+ // -map_chapters -1: Remove chapters that have timestamps from original video (causes wrong duration)
941+ // -movflags +faststart moves metadata to front for better streaming
942+ `ffmpeg -y -i "${ downloadedFile } " -map 0:v:0 -map 0:a:0 -map_chapters -1 -c copy -movflags +faststart "${ fixedFile } "` ,
943+ { encoding : 'utf-8' , timeout : 60000 }
944+ ) ;
945+ // Replace original with fixed file
946+ fs . unlinkSync ( downloadedFile ) ;
947+ fs . renameSync ( fixedFile , downloadedFile ) ;
948+ this . logger . log ( `✅ [${ id } ] Clip metadata fixed successfully` ) ;
949+ } catch ( remuxError ) {
950+ this . logger . warn ( `⚠️ [${ id } ] Failed to fix clip metadata: ${ remuxError } ` ) ;
951+ // Continue with original file if remux fails
952+ }
953+ }
954+
920955 // Upload to R2 using streaming (memory efficient)
921956 const videoTitle = ( await this . getVideoTitle ( id ) ) || 'video' ;
922- // Store original Unicode filename in DB, use simple ID-based key for R2
923- const displayFilename = `${ videoTitle . replace ( / [ < > : " / \\ | ? * \x00 - \x1f ] / g, '' ) . trim ( ) . substring ( 0 , 150 ) } .${ ext } ` ;
957+ // Build filename: use clipTitle as prefix if provided (for chapter downloads)
958+ const sanitize = ( s : string ) => s . replace ( / [ < > : " / \\ | ? * \x00 - \x1f ] / g, '' ) . trim ( ) ;
959+ let displayFilename : string ;
960+ if ( request . clipTitle && isClipMode ) {
961+ // Format: ClipTitle_VideoTitle.ext
962+ const clipPart = sanitize ( request . clipTitle ) . substring ( 0 , 50 ) ;
963+ const videoPart = sanitize ( videoTitle ) . substring ( 0 , 100 ) ;
964+ displayFilename = `${ clipPart } _${ videoPart } .${ ext } ` ;
965+ } else {
966+ displayFilename = `${ sanitize ( videoTitle ) . substring ( 0 , 150 ) } .${ ext } ` ;
967+ }
924968 const objectKey = `youtube/${ id } /video.${ ext } ` ; // Simple R2-safe key
925969
926970 // Get content type based on extension
0 commit comments