@@ -331,6 +331,43 @@ function parseProgressLine(line) {
331331 } ;
332332}
333333
334+ function parseFfmpegProgress ( line ) {
335+ const normalized = String ( line || "" ) ;
336+ const timeMatch = normalized . match ( / t i m e = ( \d + ) : ( \d + ) : ( \d + (?: \. \d + ) ? ) / ) ;
337+ if ( ! timeMatch ) return null ;
338+
339+ const hours = Number ( timeMatch [ 1 ] || 0 ) ;
340+ const minutes = Number ( timeMatch [ 2 ] || 0 ) ;
341+ const seconds = Number ( timeMatch [ 3 ] || 0 ) ;
342+ if ( [ hours , minutes , seconds ] . some ( ( value ) => Number . isNaN ( value ) ) ) {
343+ return null ;
344+ }
345+
346+ const speedMatch = normalized . match ( / s p e e d = \s * ( [ 0 - 9 ] + (?: \. [ 0 - 9 ] + ) ? ) x / i) ;
347+ const speedX = speedMatch ? Number ( speedMatch [ 1 ] ) : null ;
348+
349+ return {
350+ processedSeconds : hours * 3600 + minutes * 60 + seconds ,
351+ speedX : speedX && ! Number . isNaN ( speedX ) && speedX > 0 ? speedX : null
352+ } ;
353+ }
354+
355+ function formatEtaClock ( totalSeconds ) {
356+ if ( ! Number . isFinite ( totalSeconds ) || totalSeconds < 0 ) {
357+ return "" ;
358+ }
359+
360+ const safe = Math . max ( 0 , Math . floor ( totalSeconds ) ) ;
361+ const hours = Math . floor ( safe / 3600 ) ;
362+ const minutes = Math . floor ( ( safe % 3600 ) / 60 ) ;
363+ const seconds = safe % 60 ;
364+
365+ if ( hours > 0 ) {
366+ return `${ String ( hours ) . padStart ( 2 , "0" ) } :${ String ( minutes ) . padStart ( 2 , "0" ) } :${ String ( seconds ) . padStart ( 2 , "0" ) } ` ;
367+ }
368+ return `${ String ( minutes ) . padStart ( 2 , "0" ) } :${ String ( seconds ) . padStart ( 2 , "0" ) } ` ;
369+ }
370+
334371function sendToRenderer ( channel , payload ) {
335372 if ( mainWindow && ! mainWindow . isDestroyed ( ) ) {
336373 mainWindow . webContents . send ( channel , payload ) ;
@@ -463,6 +500,91 @@ function normalizePositiveInt(value) {
463500 return num ;
464501}
465502
503+ function parseTimestampToSeconds ( value ) {
504+ const normalized = String ( value || "" ) . trim ( ) ;
505+ if ( ! normalized ) return null ;
506+
507+ if ( ! / ^ \d + (?: : \d { 1 , 2 } ) { 0 , 2 } $ / . test ( normalized ) ) {
508+ throw new Error ( "Time must use ss, mm:ss, or hh:mm:ss." ) ;
509+ }
510+
511+ const parts = normalized . split ( ":" ) . map ( ( part ) => Number ( part ) ) ;
512+ if ( parts . some ( ( part ) => Number . isNaN ( part ) ) ) {
513+ throw new Error ( "Time contains an invalid number." ) ;
514+ }
515+
516+ if ( parts . length === 1 ) {
517+ return parts [ 0 ] ;
518+ }
519+
520+ if ( parts . length === 2 ) {
521+ const [ minutes , seconds ] = parts ;
522+ if ( seconds >= 60 ) {
523+ throw new Error ( "Seconds must be below 60 in mm:ss." ) ;
524+ }
525+ return minutes * 60 + seconds ;
526+ }
527+
528+ const [ hours , minutes , seconds ] = parts ;
529+ if ( minutes >= 60 || seconds >= 60 ) {
530+ throw new Error ( "Minutes and seconds must be below 60 in hh:mm:ss." ) ;
531+ }
532+ return hours * 3600 + minutes * 60 + seconds ;
533+ }
534+
535+ function formatSecondsAsClock ( totalSeconds ) {
536+ const safe = Math . max ( 0 , Math . floor ( Number ( totalSeconds ) || 0 ) ) ;
537+ const hours = Math . floor ( safe / 3600 ) ;
538+ const minutes = Math . floor ( ( safe % 3600 ) / 60 ) ;
539+ const seconds = safe % 60 ;
540+ return `${ String ( hours ) . padStart ( 2 , "0" ) } :${ String ( minutes ) . padStart ( 2 , "0" ) } :${ String ( seconds ) . padStart ( 2 , "0" ) } ` ;
541+ }
542+
543+ function normalizeClipRange ( { enabled, start, end, maxDurationSeconds } ) {
544+ if ( ! enabled ) {
545+ return {
546+ clipEnabled : false ,
547+ clipStart : "" ,
548+ clipEnd : "" ,
549+ clipStartSeconds : null ,
550+ clipEndSeconds : null
551+ } ;
552+ }
553+
554+ const startSeconds = parseTimestampToSeconds ( start ) ;
555+ const endSeconds = parseTimestampToSeconds ( end ) ;
556+
557+ if ( startSeconds === null || endSeconds === null ) {
558+ throw new Error ( "Provide both clip start and clip end." ) ;
559+ }
560+
561+ if ( startSeconds < 0 || endSeconds < 0 ) {
562+ throw new Error ( "Clip times must be zero or greater." ) ;
563+ }
564+
565+ if ( endSeconds <= startSeconds ) {
566+ throw new Error ( "Clip end must be greater than clip start." ) ;
567+ }
568+
569+ const maxDuration = Number ( maxDurationSeconds || 0 ) || null ;
570+ if ( maxDuration ) {
571+ if ( startSeconds >= maxDuration ) {
572+ throw new Error ( "Clip start must be inside the video duration." ) ;
573+ }
574+ if ( endSeconds > maxDuration ) {
575+ throw new Error ( "Clip end cannot exceed the video duration." ) ;
576+ }
577+ }
578+
579+ return {
580+ clipEnabled : true ,
581+ clipStart : formatSecondsAsClock ( startSeconds ) ,
582+ clipEnd : formatSecondsAsClock ( endSeconds ) ,
583+ clipStartSeconds : startSeconds ,
584+ clipEndSeconds : endSeconds
585+ } ;
586+ }
587+
466588function normalizeIndex ( rawValue , totalCount ) {
467589 const value = Number ( rawValue ) ;
468590 if ( ! Number . isInteger ( value ) || value === 0 ) {
@@ -666,6 +788,12 @@ function buildAudioStrategies() {
666788function buildDownloadArgs ( { job, strategy, ffmpegPath, effectiveRateLimit } ) {
667789 const outputTemplate = path . join ( job . outputFolder , "%(title)s.%(ext)s" ) ;
668790 const args = [ "--newline" , "--no-warnings" , "--ignore-config" , "--continue" , "-o" , outputTemplate ] ;
791+ let ffmpegLocationAdded = false ;
792+ const addFfmpegLocation = ( ) => {
793+ if ( ! ffmpegPath || ffmpegLocationAdded ) return ;
794+ args . push ( "--ffmpeg-location" , ffmpegPath ) ;
795+ ffmpegLocationAdded = true ;
796+ } ;
669797
670798 if ( job . cookiesFile ) {
671799 args . push ( "--cookies" , job . cookiesFile ) ;
@@ -690,18 +818,24 @@ function buildDownloadArgs({ job, strategy, ffmpegPath, effectiveRateLimit }) {
690818 args . push ( "--no-playlist" ) ;
691819 }
692820
821+ if ( job . clipEnabled ) {
822+ if ( ! ffmpegPath ) {
823+ throw new Error ( "Clip range download requires ffmpeg." ) ;
824+ }
825+ addFfmpegLocation ( ) ;
826+ args . push ( "--download-sections" , `*${ job . clipStart } -${ job . clipEnd } ` ) ;
827+ }
828+
693829 if ( job . mode === "audio" ) {
694830 if ( strategy . extractMp3 ) {
695- if ( ffmpegPath ) {
696- args . push ( "--ffmpeg-location" , ffmpegPath ) ;
697- }
831+ addFfmpegLocation ( ) ;
698832 args . push ( "-f" , strategy . format , "-x" , "--audio-format" , "mp3" , "--audio-quality" , "0" ) ;
699833 } else {
700834 args . push ( "-f" , strategy . format ) ;
701835 }
702836 } else {
703- if ( strategy . useFfmpeg && ffmpegPath ) {
704- args . push ( "--ffmpeg-location" , ffmpegPath ) ;
837+ if ( strategy . useFfmpeg ) {
838+ addFfmpegLocation ( ) ;
705839 }
706840 args . push ( "-f" , strategy . format ) ;
707841 if ( strategy . mergeMp4 && ffmpegPath ) {
@@ -806,11 +940,42 @@ function startJob(job) {
806940 const level = line . startsWith ( "ERROR" ) ? "error" : line . includes ( "WARNING" ) ? "warn" : "info" ;
807941 appendJobLog ( job , line , level ) ;
808942
943+ if ( job . clipEnabled && job . clipStartSeconds !== null && job . clipEndSeconds !== null ) {
944+ const clipDuration = Math . max ( 1 , Number ( job . clipEndSeconds ) - Number ( job . clipStartSeconds ) ) ;
945+ const ffmpegProgress = parseFfmpegProgress ( line ) ;
946+ if ( ffmpegProgress ) {
947+ const ffmpegPercent = Math . min ( 99 , Math . max ( 0 , ( ffmpegProgress . processedSeconds / clipDuration ) * 100 ) ) ;
948+ if ( ffmpegPercent > Number ( job . progress || 0 ) ) {
949+ const remainingSeconds = Math . max ( 0 , clipDuration - ffmpegProgress . processedSeconds ) ;
950+ const derivedEta =
951+ ffmpegProgress . speedX && ffmpegProgress . speedX > 0
952+ ? formatEtaClock ( remainingSeconds / ffmpegProgress . speedX )
953+ : "Processing" ;
954+ job . progress = ffmpegPercent ;
955+ job . speed = ffmpegProgress . speedX ? `${ ffmpegProgress . speedX . toFixed ( 2 ) } x` : "" ;
956+ job . eta = derivedEta || "Processing" ;
957+ sendToRenderer ( "video:download-progress" , {
958+ jobId : job . id ,
959+ percent : job . progress ,
960+ speed : job . speed ,
961+ eta : job . eta
962+ } ) ;
963+ }
964+ }
965+ }
966+
809967 const progress = parseProgressLine ( line ) ;
810968 if ( ! progress ) return ;
811- job . progress = Number ( progress . percent || 0 ) ;
969+
970+ let percent = Number ( progress . percent || 0 ) ;
971+ if ( job . clipEnabled && percent >= 100 ) {
972+ // For clipped downloads, keep <100 until yt-dlp process actually exits successfully.
973+ percent = 99 ;
974+ }
975+
976+ job . progress = percent ;
812977 job . speed = progress . speed || "" ;
813- job . eta = progress . eta || "" ;
978+ job . eta = progress . eta || ( job . clipEnabled && percent >= 99 ? "Processing" : "" ) ;
814979 sendToRenderer ( "video:download-progress" , {
815980 jobId : job . id ,
816981 percent : job . progress ,
@@ -1336,15 +1501,31 @@ ipcMain.handle("video:start-download", async (_event, payload) => {
13361501 const allowPlaylist = Boolean ( payload ?. allowPlaylist ) ;
13371502 const perDownloadSpeedLimit = normalizeRateLimit ( payload ?. perDownloadSpeedLimit || "" ) ;
13381503 const selectedFormatId = String ( payload ?. selectedFormatId || "auto" ) . trim ( ) || "auto" ;
1504+ const clipEnabled = Boolean ( payload ?. clipEnabled ) ;
1505+ const sourceDurationSeconds = Number ( payload ?. sourceDurationSeconds || 0 ) || null ;
13391506 const cookiesFile = normalizeCookiesFile ( payload ?. cookiesFile ) ;
13401507 const cookieBrowser = cookiesFile ? "" : normalizeCookieBrowser ( payload ?. cookieBrowser ) ;
13411508
1509+ if ( clipEnabled && allowPlaylist ) {
1510+ throw new Error ( "Clip range is available only for single videos." ) ;
1511+ }
1512+
13421513 const playlistStart = allowPlaylist ? normalizePositiveInt ( payload ?. playlistStart ) : null ;
13431514 const playlistEnd = allowPlaylist ? normalizePositiveInt ( payload ?. playlistEnd ) : null ;
13441515 if ( playlistStart && playlistEnd && playlistEnd < playlistStart ) {
13451516 throw new Error ( "Playlist end must be greater than or equal to playlist start." ) ;
13461517 }
13471518
1519+ const clipRange = normalizeClipRange ( {
1520+ enabled : clipEnabled ,
1521+ start : payload ?. clipStart ,
1522+ end : payload ?. clipEnd ,
1523+ maxDurationSeconds : sourceDurationSeconds
1524+ } ) ;
1525+ if ( clipRange . clipEnabled && ! getFfmpegPath ( ) ) {
1526+ throw new Error ( "Clip range download requires ffmpeg. Use a full download or make ffmpeg available." ) ;
1527+ }
1528+
13481529 const job = {
13491530 id : randomUUID ( ) ,
13501531 url,
@@ -1362,6 +1543,11 @@ ipcMain.handle("video:start-download", async (_event, payload) => {
13621543 playlistInclude : String ( payload ?. playlistInclude || "" ) . trim ( ) ,
13631544 playlistExclude : String ( payload ?. playlistExclude || "" ) . trim ( ) ,
13641545 playlistCount : Number ( payload ?. playlistCount || 0 ) || null ,
1546+ clipEnabled : clipRange . clipEnabled ,
1547+ clipStart : clipRange . clipStart ,
1548+ clipEnd : clipRange . clipEnd ,
1549+ clipStartSeconds : clipRange . clipStartSeconds ,
1550+ clipEndSeconds : clipRange . clipEndSeconds ,
13651551 perDownloadSpeedLimit,
13661552 status : JOB_STATUS . QUEUED ,
13671553 progress : 0 ,
@@ -1375,6 +1561,9 @@ ipcMain.handle("video:start-download", async (_event, payload) => {
13751561 } ;
13761562
13771563 appendJobLog ( job , "Added to queue." ) ;
1564+ if ( clipRange . clipEnabled ) {
1565+ appendJobLog ( job , `Clip range: ${ clipRange . clipStart } to ${ clipRange . clipEnd } .` ) ;
1566+ }
13781567 jobsById . set ( job . id , job ) ;
13791568 queuedJobIds . push ( job . id ) ;
13801569
0 commit comments