Skip to content

Commit 8b293a7

Browse files
authored
Merge pull request #9 from Shripad735/pre-prod
Implement clip range functionality for video downloads with UI support
2 parents 1416a3f + f39abe2 commit 8b293a7

5 files changed

Lines changed: 415 additions & 19 deletions

File tree

electron/main.js

Lines changed: 196 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,43 @@ function parseProgressLine(line) {
331331
};
332332
}
333333

334+
function parseFfmpegProgress(line) {
335+
const normalized = String(line || "");
336+
const timeMatch = normalized.match(/time=(\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(/speed=\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+
334371
function 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+
466588
function normalizeIndex(rawValue, totalCount) {
467589
const value = Number(rawValue);
468590
if (!Number.isInteger(value) || value === 0) {
@@ -666,6 +788,12 @@ function buildAudioStrategies() {
666788
function 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

Comments
 (0)