Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 33 additions & 15 deletions src/Ytdlp.NET.Console/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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()
Expand All @@ -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}");
Expand Down Expand Up @@ -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);
}

Expand All @@ -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)
Expand Down
11 changes: 4 additions & 7 deletions src/Ytdlp.NET/Core/DownloadRunner.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Diagnostics;

namespace ManuHub.Ytdlp.NET.Core;
namespace ManuHub.Ytdlp.NET.Core;

public sealed class DownloadRunner
{
Expand Down Expand Up @@ -88,17 +86,16 @@ void Complete(bool success, string message)

// Ensure process is dead
if (!process.HasExited)
{
ProcessFactory.SafeKill(process, _logger);
}
ProcessFactory.SafeKill(process);

try
{
await process.WaitForExitAsync(ct);
}
catch (OperationCanceledException)
{
ProcessFactory.SafeKill(process, _logger);
if (!process.HasExited)
ProcessFactory.SafeKill(process);
}

var success = process.ExitCode == 0 && !ct.IsCancellationRequested;
Expand Down
17 changes: 7 additions & 10 deletions src/Ytdlp.NET/Core/ProcessFactory.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics;
using ManuHub.Ytdlp.NET.Extensions;
using System.Diagnostics;
using System.Text;

namespace ManuHub.Ytdlp.NET.Core;
Expand All @@ -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,
Expand Down Expand Up @@ -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}");
}
}
}
109 changes: 109 additions & 0 deletions src/Ytdlp.NET/Extensions/ProcessExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System.Diagnostics;
using System.Runtime.InteropServices;

namespace ManuHub.Ytdlp.NET.Extensions;

/// <summary>
/// Process extensions for killing full process tree.
/// </summary>
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<int>();
GetAllChildIdsUnix(process.Id, children, timeout);
foreach (var childId in children)
{
KillProcessUnix(childId, timeout);
}
KillProcessUnix(process.Id, timeout);
}
}

private static void GetAllChildIdsUnix(int parentId, ISet<int> 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;

Check warning on line 96 in src/Ytdlp.NET/Extensions/ProcessExtensions.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.

Check warning on line 96 in src/Ytdlp.NET/Extensions/ProcessExtensions.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.

Check warning on line 96 in src/Ytdlp.NET/Extensions/ProcessExtensions.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.

Check warning on line 96 in src/Ytdlp.NET/Extensions/ProcessExtensions.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.
if (process.WaitForExit((int)timeout.TotalMilliseconds))

Check warning on line 97 in src/Ytdlp.NET/Extensions/ProcessExtensions.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

Check warning on line 97 in src/Ytdlp.NET/Extensions/ProcessExtensions.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.

Check warning on line 97 in src/Ytdlp.NET/Extensions/ProcessExtensions.cs

View workflow job for this annotation

GitHub Actions / build

Dereference of a possibly null reference.
{
stdout = process.StandardOutput.ReadToEnd();
}
else
{
process.Kill();
}

return process.ExitCode;
}
}

2 changes: 1 addition & 1 deletion src/Ytdlp.NET/Parsing/ProgressParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 2 additions & 3 deletions src/Ytdlp.NET/Parsing/RegexPatterns.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ internal static class RegexPatterns
public const string DownloadDestination = @"\[download\]\s*Destination:\s*(?<path>.+)";
public const string ResumeDownload = @"\[download\]\s*Resuming download at byte\s*(?<byte>\d+)";
public const string DownloadAlreadyDownloaded = @"\[download\]\s*(?<path>[^\n]+?)\s*has already been downloaded";
public const string DownloadProgress = @"\[download\]\s*(?<percent>\d+\.\d+)%\s*of\s*(?<size>[^\s]+)\s*at\s*(?<speed>[^\s]+)\s*ETA\s*(?<eta>[^\s]+)";
public const string DownloadProgress = @"\[download\]\s+(?:(?<percent>[\d\.]+)%(?:\s+of\s+\~?\s*(?<total>[\d\.\w]+))?\s+at\s+(?:(?<speed>[\d\.\w]+\/s)|[\w\s]+)\s+ETA\s(?<eta>[\d\:]+))?";
//@"\[download\]\s*(?<percent>\d+\.\d+)%\s*of\s*(?<size>[^\s]+)\s*at\s*(?<speed>[^\s]+)\s*ETA\s*(?<eta>[^\s]+)";
public const string DownloadProgressWithFrag = @"\[download\]\s*(?<percent>\d+\.\d+)%\s*of\s*(~?\s*(?<size>[^\s]+))\s*at\s*(?<speed>[^\s]+)\s*ETA\s*(?<eta>[^\s]+)\s*\(frag\s*(?<frag>\d+/\d+)\)";
public const string DownloadProgressComplete = @"\[download\]\s*(?<percent>100(?:\.0)?)%\s*of\s*(?<size>[^\s]+)\s*at\s*(?<speed>[^\s]+|Unknown)\s*ETA\s*(?<eta>[^\s]+|Unknown)";
public const string UnknownError = @"\[download\]\s*Unknown error";
Expand All @@ -27,8 +28,6 @@ internal static class RegexPatterns
public const string SpecificError = @"\[(?<source>[^\]]+)\]\s*(?<id>[^\s:]+):\s*ERROR:\s*(?<error>.+)";
public const string DownloadingSubtitles = @"\[info\]\s*Downloading subtitles:\s*(?<language>[^\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)";

Expand Down
9 changes: 8 additions & 1 deletion src/Ytdlp.NET/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ await ytdlp.DownloadBatchAsync(urls, maxConcurrency: 3);
* `.WithJsRuntime(Runtime runtime, string runtimePath)`
* `.WithNoJsRuntime()`
* `.WithFlatPlaylist()`
* ` WithLiveFromStart()`
* `.WithLiveFromStart()`
* `.WithWaitForVideo(TimeSpan? maxWait = null)`
* `.WithMarkWatched()`

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/Ytdlp.NET/Ytdlp.NET.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<PackageId>Ytdlp.NET</PackageId>
<AssemblyName>Ytdlp.NET</AssemblyName>
<RootNamespace>ManuHub.Ytdlp.NET</RootNamespace>
<Version>3.0.1</Version>
<Version>3.0.2</Version>
<Authors>ManuHub Manojbabu</Authors>
<PackageDescription>A .NET wrapper for yt-dlp with advanced features like concurrent downloads, SponsorBlock, and improved format parsing</PackageDescription>
<Copyright>© 2025-2026 ManuHub. Allrights researved</Copyright>
Expand Down
4 changes: 4 additions & 0 deletions src/Ytdlp.NET/Ytdlp.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -1366,6 +1367,9 @@ public async Task<List<string>> 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))
Expand Down
Loading