From ff99224bb0ad279141c4979f0c32d0bf5e7842ea Mon Sep 17 00:00:00 2001 From: manusoft Date: Sun, 5 Apr 2026 02:24:04 +0400 Subject: [PATCH] Improve process termination, progress parsing, and docs - Add ProcessExtensions.KillTree for reliable cross-platform process tree termination; update ProcessFactory to use it - Enhance download progress regex and parsing for better accuracy - Improve cancellation handling and add pre-checks in Ytdlp - Update demo/test code: comment out most tests, improve progress/cancellation output, change test URLs - Document AddFlag/AddOption in README, fix minor typos, add table entry - Bump version to 3.0.2 --- src/Ytdlp.NET.Console/Program.cs | 48 +++++--- src/Ytdlp.NET/Core/DownloadRunner.cs | 11 +- src/Ytdlp.NET/Core/ProcessFactory.cs | 17 ++- src/Ytdlp.NET/Extensions/ProcessExtensions.cs | 109 ++++++++++++++++++ src/Ytdlp.NET/Parsing/ProgressParser.cs | 2 +- src/Ytdlp.NET/Parsing/RegexPatterns.cs | 5 +- src/Ytdlp.NET/README.md | 9 +- src/Ytdlp.NET/Ytdlp.NET.csproj | 2 +- src/Ytdlp.NET/Ytdlp.cs | 4 + 9 files changed, 169 insertions(+), 38 deletions(-) create mode 100644 src/Ytdlp.NET/Extensions/ProcessExtensions.cs diff --git a/src/Ytdlp.NET.Console/Program.cs b/src/Ytdlp.NET.Console/Program.cs index 78487ac..431edf3 100644 --- a/src/Ytdlp.NET.Console/Program.cs +++ b/src/Ytdlp.NET.Console/Program.cs @@ -19,20 +19,20 @@ private static async Task Main(string[] args) .WithFFmpegLocation("tools"); // Run all demos/tests sequentially - await TestGetVersionAsync(baseYtdlp); - await TestUpdateAsync(baseYtdlp); + //await TestGetVersionAsync(baseYtdlp); + //await TestUpdateAsync(baseYtdlp); - await TestGetFormatsAsync(baseYtdlp); - await TestGetMetadataAsync(baseYtdlp); - await TestGetLiteMetadataAsync(baseYtdlp); - await TestGetTitleAsync(baseYtdlp); + //await TestGetFormatsAsync(baseYtdlp); + //await TestGetMetadataAsync(baseYtdlp); + //await TestGetLiteMetadataAsync(baseYtdlp); + //await TestGetTitleAsync(baseYtdlp); await TestDownloadVideoAsync(baseYtdlp); - await TestDownloadAudioAsync(baseYtdlp); - await TestBatchDownloadAsync(baseYtdlp); - await TestSponsorBlockAsync(baseYtdlp); - await TestConcurrentFragmentsAsync(baseYtdlp); - await TestCancellationAsync(baseYtdlp); + //await TestDownloadAudioAsync(baseYtdlp); + //await TestBatchDownloadAsync(baseYtdlp); + //await TestSponsorBlockAsync(baseYtdlp); + //await TestConcurrentFragmentsAsync(baseYtdlp); + //await TestCancellationAsync(baseYtdlp); var lists = await baseYtdlp.ExtractorsAsync(); @@ -104,10 +104,12 @@ private static async Task TestGetMetadataAsync(Ytdlp ytdlp) var stopwatch = Stopwatch.StartNew(); Console.WriteLine("\nTest 4: Fetching detailed metedata..."); - + var token = new CancellationTokenSource().Token; // In real use, you might want to cancel if it takes too long + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(90)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token, timeoutCts.Token); var url1 = "https://www.youtube.com/watch?v=983bBbJx0Mk&list=RD983bBbJx0Mk&start_radio=1&pp=ygUFc29uZ3OgBwE%3D"; //playlist var url2 = "https://www.youtube.com/watch?v=ZGnQH0LN_98"; // video - var metadata = await ytdlp.GetMetadataAsync(url1); + var metadata = await ytdlp.GetMetadataAsync(url2, linkedCts.Token); stopwatch.Stop(); // stop timer Console.WriteLine($"Detailed metedata took {stopwatch.Elapsed.TotalSeconds:F3} seconds"); @@ -157,7 +159,7 @@ private static async Task TestGetLiteMetadataAsync(Ytdlp ytdlp) private static async Task TestDownloadVideoAsync(Ytdlp ytdlpBase) { Console.WriteLine("\nTest 6: Downloading a video..."); - var url = "https://www.youtube.com/watch?v=3pecPwPIFIc&pp=ugUEEgJtbA%3D%3D"; + var url = "https://www.youtube.com/watch?v=89-i4aPOMrc"; var ytdlp = ytdlpBase .With720pOrBest() @@ -170,7 +172,7 @@ private static async Task TestDownloadVideoAsync(Ytdlp ytdlpBase) // Subscribe to events ytdlp.OnProgressDownload += (sender, args) => - Console.WriteLine($"Progress: {args.Percent:F2}% - {args.Speed} - ETA {args.ETA}"); + Console.WriteLine($"Progress: {args.Percent:F2}% - {args.Speed} - ETA {args.ETA} - Size {args.Size}"); ytdlp.OnCompleteDownload += (sender, message) => Console.WriteLine($"Download complete: {message}"); @@ -240,6 +242,9 @@ private static async Task TestConcurrentFragmentsAsync(Ytdlp ytdlpBase) .WithOutputTemplate("%(title)s.%(ext)s") .WithOutputFolder("./downloads/concurrent"); + ytdlp.OnProgressDownload += (sender, args) => + Console.WriteLine($"Progress: {args.Percent:F2}% - {args.Speed} - ETA {args.ETA} - FRA {args.Fragments}"); + await ytdlp.DownloadAsync(url); } @@ -250,18 +255,31 @@ private static async Task TestCancellationAsync(Ytdlp ytdlp) var url = "https://www.youtube.com/watch?v=zGlwuHqGVIA"; // A longer video var cts = new CancellationTokenSource(); + var downloadTask = ytdlp .WithFormat("b") .WithOutputTemplate("%(title)s.%(ext)s") .WithOutputFolder("./downloads/cancel") .DownloadAsync(url, cts.Token); + ytdlp.OnCommandCompleted += (sender, args) => + { + if (args.Success) + Console.WriteLine("Download completed successfully."); + else if (args.Message.Contains("cancelled", StringComparison.OrdinalIgnoreCase)) + Console.WriteLine("Download was cancelled."); + else + Console.WriteLine($"Download failed: {args.Message}"); + }; + // Simulate cancel after 20 seconds await Task.Delay(20000); cts.Cancel(); try { + + await downloadTask; } catch (OperationCanceledException) diff --git a/src/Ytdlp.NET/Core/DownloadRunner.cs b/src/Ytdlp.NET/Core/DownloadRunner.cs index ddee76c..32db8d1 100644 --- a/src/Ytdlp.NET/Core/DownloadRunner.cs +++ b/src/Ytdlp.NET/Core/DownloadRunner.cs @@ -1,6 +1,4 @@ -using System.Diagnostics; - -namespace ManuHub.Ytdlp.NET.Core; +namespace ManuHub.Ytdlp.NET.Core; public sealed class DownloadRunner { @@ -88,9 +86,7 @@ void Complete(bool success, string message) // Ensure process is dead if (!process.HasExited) - { - ProcessFactory.SafeKill(process, _logger); - } + ProcessFactory.SafeKill(process); try { @@ -98,7 +94,8 @@ void Complete(bool success, string message) } catch (OperationCanceledException) { - ProcessFactory.SafeKill(process, _logger); + if (!process.HasExited) + ProcessFactory.SafeKill(process); } var success = process.ExitCode == 0 && !ct.IsCancellationRequested; diff --git a/src/Ytdlp.NET/Core/ProcessFactory.cs b/src/Ytdlp.NET/Core/ProcessFactory.cs index b62e7af..74d39fc 100644 --- a/src/Ytdlp.NET/Core/ProcessFactory.cs +++ b/src/Ytdlp.NET/Core/ProcessFactory.cs @@ -1,4 +1,5 @@ -using System.Diagnostics; +using ManuHub.Ytdlp.NET.Extensions; +using System.Diagnostics; using System.Text; namespace ManuHub.Ytdlp.NET.Core; @@ -24,7 +25,6 @@ public Process Create(string arguments) FileName = _ytdlpPath, Arguments = arguments, - // Must for async/event-based reading RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = true, @@ -75,18 +75,15 @@ public static void SafeKill(Process process, ILogger? logger = null) if (process.HasExited) return; - // Close streams first → prevents ReadLine hang - try { process.StandardOutput?.Close(); } catch { } - try { process.StandardError?.Close(); } catch { } - try { process.StandardInput?.Close(); } catch { } + process.KillTree(); - process.Kill(entireProcessTree: true); - - logger?.Log(LogType.Info, "Process killed (entire tree)"); + if(logger != null) + logger.Log(LogType.Info, "Process killed (entire tree)"); } catch (Exception ex) { - logger?.Log(LogType.Error, $"Failed to kill process: {ex.Message}"); + if(logger != null) + logger.Log(LogType.Error, $"Failed to kill process: {ex.Message}"); } } } \ No newline at end of file diff --git a/src/Ytdlp.NET/Extensions/ProcessExtensions.cs b/src/Ytdlp.NET/Extensions/ProcessExtensions.cs new file mode 100644 index 0000000..5527dde --- /dev/null +++ b/src/Ytdlp.NET/Extensions/ProcessExtensions.cs @@ -0,0 +1,109 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace ManuHub.Ytdlp.NET.Extensions; + +/// +/// Process extensions for killing full process tree. +/// +internal static class ProcessExtensions +{ + private static readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); + + public static void KillTree(this Process process) + { + process.KillTree(_defaultTimeout); + } + + public static void KillTree(this Process process, TimeSpan timeout) + { + string stdout; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + RunProcessAndWaitForExit( + "taskkill", + $"/T /F /PID {process.Id}", + timeout, + out stdout); + } + else + { + var children = new HashSet(); + GetAllChildIdsUnix(process.Id, children, timeout); + foreach (var childId in children) + { + KillProcessUnix(childId, timeout); + } + KillProcessUnix(process.Id, timeout); + } + } + + private static void GetAllChildIdsUnix(int parentId, ISet children, TimeSpan timeout) + { + string stdout; + var exitCode = RunProcessAndWaitForExit( + "pgrep", + $"-P {parentId}", + timeout, + out stdout); + + if (exitCode == 0 && !string.IsNullOrEmpty(stdout)) + { + using (var reader = new StringReader(stdout)) + { + while (true) + { + var text = reader.ReadLine(); + if (text == null) + { + return; + } + + int id; + if (int.TryParse(text, out id)) + { + children.Add(id); + GetAllChildIdsUnix(id, children, timeout); + } + } + } + } + } + + private static void KillProcessUnix(int processId, TimeSpan timeout) + { + string stdout; + RunProcessAndWaitForExit( + "kill", + $"-TERM {processId}", + timeout, + out stdout); + } + + private static int RunProcessAndWaitForExit(string fileName, string arguments, TimeSpan timeout, out string stdout) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + var process = Process.Start(startInfo); + + stdout = null; + if (process.WaitForExit((int)timeout.TotalMilliseconds)) + { + stdout = process.StandardOutput.ReadToEnd(); + } + else + { + process.Kill(); + } + + return process.ExitCode; + } +} + diff --git a/src/Ytdlp.NET/Parsing/ProgressParser.cs b/src/Ytdlp.NET/Parsing/ProgressParser.cs index 865b645..1dcf906 100644 --- a/src/Ytdlp.NET/Parsing/ProgressParser.cs +++ b/src/Ytdlp.NET/Parsing/ProgressParser.cs @@ -183,7 +183,7 @@ private void HandleDownloadProgress(Match match) { // Existing logic unchanged string percentString = match.Groups["percent"].Value; - string sizeString = match.Groups["size"].Value; + string sizeString = match.Groups["total"].Value; string speedString = match.Groups["speed"].Value; string etaString = match.Groups["eta"].Value; diff --git a/src/Ytdlp.NET/Parsing/RegexPatterns.cs b/src/Ytdlp.NET/Parsing/RegexPatterns.cs index 3ef0751..f1dca10 100644 --- a/src/Ytdlp.NET/Parsing/RegexPatterns.cs +++ b/src/Ytdlp.NET/Parsing/RegexPatterns.cs @@ -17,7 +17,8 @@ internal static class RegexPatterns public const string DownloadDestination = @"\[download\]\s*Destination:\s*(?.+)"; public const string ResumeDownload = @"\[download\]\s*Resuming download at byte\s*(?\d+)"; public const string DownloadAlreadyDownloaded = @"\[download\]\s*(?[^\n]+?)\s*has already been downloaded"; - public const string DownloadProgress = @"\[download\]\s*(?\d+\.\d+)%\s*of\s*(?[^\s]+)\s*at\s*(?[^\s]+)\s*ETA\s*(?[^\s]+)"; + public const string DownloadProgress = @"\[download\]\s+(?:(?[\d\.]+)%(?:\s+of\s+\~?\s*(?[\d\.\w]+))?\s+at\s+(?:(?[\d\.\w]+\/s)|[\w\s]+)\s+ETA\s(?[\d\:]+))?"; + //@"\[download\]\s*(?\d+\.\d+)%\s*of\s*(?[^\s]+)\s*at\s*(?[^\s]+)\s*ETA\s*(?[^\s]+)"; public const string DownloadProgressWithFrag = @"\[download\]\s*(?\d+\.\d+)%\s*of\s*(~?\s*(?[^\s]+))\s*at\s*(?[^\s]+)\s*ETA\s*(?[^\s]+)\s*\(frag\s*(?\d+/\d+)\)"; public const string DownloadProgressComplete = @"\[download\]\s*(?100(?:\.0)?)%\s*of\s*(?[^\s]+)\s*at\s*(?[^\s]+|Unknown)\s*ETA\s*(?[^\s]+|Unknown)"; public const string UnknownError = @"\[download\]\s*Unknown error"; @@ -27,8 +28,6 @@ internal static class RegexPatterns public const string SpecificError = @"\[(?[^\]]+)\]\s*(?[^\s:]+):\s*ERROR:\s*(?.+)"; public const string DownloadingSubtitles = @"\[info\]\s*Downloading subtitles:\s*(?[^\s]+)"; - // ───────────── New / Enhanced Patterns for v2.0 ───────────── - // More reliable merger success detection (variation of "successfully merged") public const string MergerSuccess = @"(?:has been successfully merged|merged formats successfully)"; diff --git a/src/Ytdlp.NET/README.md b/src/Ytdlp.NET/README.md index f5124e3..438a86b 100644 --- a/src/Ytdlp.NET/README.md +++ b/src/Ytdlp.NET/README.md @@ -239,7 +239,7 @@ await ytdlp.DownloadBatchAsync(urls, maxConcurrency: 3); * `.WithJsRuntime(Runtime runtime, string runtimePath)` * `.WithNoJsRuntime()` * `.WithFlatPlaylist()` -* ` WithLiveFromStart()` +* `.WithLiveFromStart()` * `.WithWaitForVideo(TimeSpan? maxWait = null)` * `.WithMarkWatched()` @@ -436,9 +436,16 @@ await ytdlp.DownloadAsync(url); | `SetFFMpegLocation()` | `WithFFmpegLocation()` | | `ExtractAudio()` | `WithExtractAudio()` | | `UseProxy()` | `WithProxy()` | +| `AddCustomCommand()` | `AddFlag(string flag)` or `AddOption(string key, string value)` | --- +## Custom commands +```csharp +AddFlag("--no-check-certificate"); +AddOption("--external-downloader", "aria2c"); +``` + ## Important behavior changes ### Instances are immutable diff --git a/src/Ytdlp.NET/Ytdlp.NET.csproj b/src/Ytdlp.NET/Ytdlp.NET.csproj index a1ce2ce..cb1874d 100644 --- a/src/Ytdlp.NET/Ytdlp.NET.csproj +++ b/src/Ytdlp.NET/Ytdlp.NET.csproj @@ -7,7 +7,7 @@ Ytdlp.NET Ytdlp.NET ManuHub.Ytdlp.NET - 3.0.1 + 3.0.2 ManuHub Manojbabu A .NET wrapper for yt-dlp with advanced features like concurrent downloads, SponsorBlock, and improved format parsing © 2025-2026 ManuHub. Allrights researved diff --git a/src/Ytdlp.NET/Ytdlp.cs b/src/Ytdlp.NET/Ytdlp.cs index 5554e3b..420979a 100644 --- a/src/Ytdlp.NET/Ytdlp.cs +++ b/src/Ytdlp.NET/Ytdlp.cs @@ -1,5 +1,6 @@ using ManuHub.Ytdlp.NET.Core; using System.Collections.Immutable; +using System.Diagnostics; using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; @@ -1366,6 +1367,9 @@ public async Task> ExtractorsAsync(CancellationToken ct = default, $"--no-warnings " + $"{Quote(url)}"; + if(ct.IsCancellationRequested) + Debug.WriteLine("Cancellation requested before starting the process."); + var json = await Probe().RunAsync(arguments, ct, tuneProcess, bufferKb); if (string.IsNullOrWhiteSpace(json))