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
29 changes: 16 additions & 13 deletions src/Ytdlp.NET.Console/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,19 +180,20 @@ private static async Task TestDownloadVideoAsync(Ytdlp ytdlpBase)

Console.WriteLine(ytdlp.Preview(url));

// await ytdlp.ExecuteAsync(url);
await ytdlp.DownloadAsync(url);
}

private static async Task TestDownloadAudioAsync(Ytdlp ytdlp)
private static async Task TestDownloadAudioAsync(Ytdlp ytdlpBase)
{
Console.WriteLine("\nTest 7: Extracting audio...");
var url = "https://www.youtube.com/watch?v=ZGnQH0LN_98";

await ytdlp
var ytdlp = ytdlpBase
.WithExtractAudio(AudioFormat.Mp3)
.WithFormat("ba")
.WithOutputFolder("./downloads/audio")
.DownloadAsync(url);
.WithOutputFolder("./downloads/audio");

await ytdlp.DownloadAsync(url);
}

// Test 8: Batch download (concurrent)
Expand All @@ -214,30 +215,32 @@ private static async Task TestBatchDownloadAsync(Ytdlp baseYtdlp)
}

// Test 9: SponsorBlock removal
private static async Task TestSponsorBlockAsync(Ytdlp ytdlp)
private static async Task TestSponsorBlockAsync(Ytdlp ytdlpBase)
{
Console.WriteLine("\nTest 9: Download with SponsorBlock removal...");
var url = "https://www.youtube.com/watch?v=oDSEGkT6J-0";

await ytdlp
var ytdlp = ytdlpBase
.WithFormat("best")
.WithSponsorblockRemove("all") // Removes sponsor, intro, etc.
.WithOutputFolder("./downloads/sponsorblock")
.DownloadAsync(url);
.WithOutputFolder("./downloads/sponsorblock");

await ytdlp.DownloadAsync(url);
}

// Test 10: Concurrent fragments (faster download)
private static async Task TestConcurrentFragmentsAsync(Ytdlp ytdlp)
private static async Task TestConcurrentFragmentsAsync(Ytdlp ytdlpBase)
{
Console.WriteLine("\nTest 10: Download with concurrent fragments...");
var url = "https://www.youtube.com/watch?v=oDSEGkT6J-0";

await ytdlp
var ytdlp = ytdlpBase
.WithConcurrentFragments(8) // 8 parallel fragments
.WithFormat("b")
.WithOutputTemplate("%(title)s.%(ext)s")
.WithOutputFolder("./downloads/concurrent")
.DownloadAsync(url);
.WithOutputFolder("./downloads/concurrent");

await ytdlp.DownloadAsync(url);
}

// Test 11: Cancellation support
Expand Down
121 changes: 75 additions & 46 deletions src/Ytdlp.NET/Core/DownloadRunner.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace ManuHub.Ytdlp.NET.Core;
using System.Diagnostics;

namespace ManuHub.Ytdlp.NET.Core;

public sealed class DownloadRunner
{
Expand All @@ -17,82 +19,109 @@ public DownloadRunner(ProcessFactory factory, ProgressParser parser, ILogger log
_logger = logger;
}

public async Task RunAsync(string arguments, CancellationToken ct)
public async Task RunAsync(string arguments, CancellationToken ct, bool tuneProcess = true)
{
var process = _factory.Create(arguments);
using var process = _factory.Create(arguments);

int completed = 0;

void Complete(bool success, string message)
{
if (Interlocked.Exchange(ref completed, 1) == 0)
{
OnCommandCompleted?.Invoke(this, new CommandCompletedEventArgs(success, message));
}
}

try
{
if (!process.Start())
throw new YtdlpException("Failed to start yt-dlp process.");
var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);

// ✅ Attach BEFORE Start (fix race condition)
process.Exited += (_, _) => tcs.TrySetResult(true);

// Improved cancellation: Try to close streams first, then kill
using var ctsRegistration = ct.Register(() =>
process.OutputDataReceived += (s, e) =>
{
if (e.Data == null) return;

try
{
if (!process.HasExited)
{
process.Kill(entireProcessTree: true);
_logger.Log(LogType.Info, "yt-dlp process killed due to cancellation");
}
_progressParser.ParseProgress(e.Data);
OnProgress?.Invoke(this, e.Data);
}
catch
catch (Exception ex)
{
// silent - already dead or disposed
_logger.Log(LogType.Error, $"Parse error: {ex.Message}");
}
});
};

// Read output and error concurrently
var outputTask = Task.Run(async () =>
process.ErrorDataReceived += (s, e) =>
{
string? line;
while ((line = await process.StandardOutput.ReadLineAsync()) != null)
{
ct.ThrowIfCancellationRequested();
_progressParser.ParseProgress(line);
OnProgress?.Invoke(this, line);
}
}, ct);
if (e.Data == null) return;

OnErrorMessage?.Invoke(this, e.Data);
_logger.Log(LogType.Error, e.Data);
};

var errorTask = Task.Run(async () =>
if (!process.Start())
throw new YtdlpException("Failed to start yt-dlp process.");

if (tuneProcess)
ProcessFactory.Tune(process);

// ✅ Start reading AFTER handlers
process.BeginOutputReadLine();
process.BeginErrorReadLine();

// 🔥 Cancellation
using var registration = ct.Register(() =>
{
string? line;
while ((line = await process.StandardError.ReadLineAsync()) != null)
if (!process.HasExited)
{
ct.ThrowIfCancellationRequested();
OnErrorMessage?.Invoke(this, line);
_logger.Log(LogType.Error, line);
_logger.Log(LogType.Info, "Cancellation requested → killing process tree");
ProcessFactory.SafeKill(process, _logger);
}
}, ct);
});

await Task.WhenAll(outputTask, errorTask);
// Wait for exit OR cancellation
await Task.WhenAny(tcs.Task, Task.Delay(Timeout.Infinite, ct));

// Wait for exit (may throw OperationCanceledException)
await process.WaitForExitAsync(ct);
// Ensure process is dead
if (!process.HasExited)
{
ProcessFactory.SafeKill(process, _logger);
}

// Only throw on real failure (not cancellation)
if (process.ExitCode != 0 && !ct.IsCancellationRequested)
try
{
throw new YtdlpException($"yt-dlp exited with code {process.ExitCode}");
await process.WaitForExitAsync(ct);
}
catch (OperationCanceledException)
{
ProcessFactory.SafeKill(process, _logger);
}

var success = process.ExitCode == 0 && !ct.IsCancellationRequested;

var message = success
? "Completed successfully"
: ct.IsCancellationRequested
? "Cancelled by user"
: $"Failed with exit code {process.ExitCode}";

// Success or intentional cancel
var success = !ct.IsCancellationRequested;
var message = success ? "Completed successfully" : "Cancelled by user";
OnCommandCompleted?.Invoke(this, new CommandCompletedEventArgs(success, message));
Complete(success, message);
}
catch (OperationCanceledException)
{
// Normal cancel path — no need to log again
OnCommandCompleted?.Invoke(this, new CommandCompletedEventArgs(false, "Cancelled by user"));
throw; // let caller handle if needed
Complete(false, "Cancelled by user");
throw;
}
catch (Exception ex)
{
var msg = $"Error executing yt-dlp: {ex.Message}";
OnErrorMessage?.Invoke(this, msg);
_logger.Log(LogType.Error, msg);
OnErrorMessage?.Invoke(this, msg);

throw new YtdlpException(msg, ex);
}
}
Expand Down
119 changes: 89 additions & 30 deletions src/Ytdlp.NET/Core/ProbeRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,62 +7,121 @@
private readonly ProcessFactory _factory;
private readonly ILogger _logger;

public event EventHandler<string>? OnOutput; // optional: for live output if needed

Check warning on line 10 in src/Ytdlp.NET/Core/ProbeRunner.cs

View workflow job for this annotation

GitHub Actions / build

The event 'ProbeRunner.OnOutput' is never used

Check warning on line 10 in src/Ytdlp.NET/Core/ProbeRunner.cs

View workflow job for this annotation

GitHub Actions / build

The event 'ProbeRunner.OnOutput' is never used

Check warning on line 10 in src/Ytdlp.NET/Core/ProbeRunner.cs

View workflow job for this annotation

GitHub Actions / build

The event 'ProbeRunner.OnOutput' is never used

Check warning on line 10 in src/Ytdlp.NET/Core/ProbeRunner.cs

View workflow job for this annotation

GitHub Actions / build

The event 'ProbeRunner.OnOutput' is never used

Check warning on line 10 in src/Ytdlp.NET/Core/ProbeRunner.cs

View workflow job for this annotation

GitHub Actions / build

The event 'ProbeRunner.OnOutput' is never used

Check warning on line 10 in src/Ytdlp.NET/Core/ProbeRunner.cs

View workflow job for this annotation

GitHub Actions / build

The event 'ProbeRunner.OnOutput' is never used
public event EventHandler<string>? OnErrorMessage;
public event EventHandler<CommandCompletedEventArgs>? OnCommandCompleted;

public ProbeRunner(ProcessFactory factory, ILogger logger)
{
_factory = factory;
_logger = logger;
}

public async Task<string?> RunAsync(string args, CancellationToken ct = default, int bufferKb = 128)
public async Task<string?> RunAsync(string args, CancellationToken ct = default, bool tuneProcess = true, int bufferKb = 256)
{
var process = _factory.Create(args);
if (string.IsNullOrWhiteSpace(args))
throw new ArgumentException("Arguments cannot be empty", nameof(args));

// Validate buffer size: minimum 8 KB
if (bufferKb < 8) bufferKb = 8;
// Reasonable buffer: 256 KB default (good for large JSON), min 64 KB
if (bufferKb < 64) bufferKb = 64;
int bufferSize = bufferKb * 1024;

try
using var process = _factory.Create(args);

var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
int completed = 0;

void Complete(bool success, string message)
{
process.Start();

// Use StreamReader with large buffer + explicit UTF-8
string output;
using (var reader = new StreamReader(process.StandardOutput.BaseStream,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
bufferSize: bufferSize, // default 8kb for JSON
leaveOpen: true)) // don't close underlying stream
if (Interlocked.Exchange(ref completed, 1) == 0)
{
output = await reader.ReadToEndAsync();
OnCommandCompleted?.Invoke(this, new CommandCompletedEventArgs(success, message));
}
}

// Optional: drain stderr in background (prevents blocking if warnings are many)
_ = Task.Run(() => process.StandardError.ReadToEndAsync(), ct);
try
{
// Attach Exited handler BEFORE starting
process.Exited += (_, _) => tcs.TrySetResult(true);

using (ct.Register(() =>
{
try { if (!process.HasExited) process.Kill(true); } catch { }
}))
// Handle stderr (warnings, errors, verbose info from yt-dlp)
process.ErrorDataReceived += (s, e) =>
{
await process.WaitForExitAsync(ct);
}
if (!string.IsNullOrEmpty(e.Data))
{
OnErrorMessage?.Invoke(this, e.Data);
_logger.Log(LogType.Warning, e.Data);
}
};

if (!process.Start())
throw new YtdlpException("Failed to start yt-dlp probe process.");

if (tuneProcess)
ProcessFactory.Tune(process);

if (string.IsNullOrWhiteSpace(output))
process.BeginErrorReadLine(); // Only stderr uses events

// 🔥 Large-buffered reader for heavy JSON on stdout
using var reader = new StreamReader(
process.StandardOutput.BaseStream,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: false,
bufferSize: bufferSize,
leaveOpen: true);

// Start reading asynchronously
var readTask = reader.ReadToEndAsync(ct); // Pass ct for better cancellation support

// Cancellation support
using var registration = ct.Register(() =>
{
_logger.Log(LogType.Warning, "Empty output.");
return null;
}
if (!process.HasExited)
{
_logger.Log(LogType.Info, "Probe cancellation requested → SafeKill");
ProcessFactory.SafeKill(process, _logger);
}
});

// Wait for either process exit, read completion, or cancellation
await Task.WhenAny(tcs.Task, readTask, Task.Delay(Timeout.Infinite, ct));

// Ensure process is terminated if still running
if (!process.HasExited)
ProcessFactory.SafeKill(process, _logger);

// Wait for clean exit
await process.WaitForExitAsync(ct);

// Get the full output
string output = await readTask;

// Determine success
bool success = process.ExitCode == 0
&& !ct.IsCancellationRequested
&& !string.IsNullOrWhiteSpace(output);

string message = success ? "Probe completed successfully" :
ct.IsCancellationRequested ? "Probe cancelled by user" :
$"Probe failed with exit code {process.ExitCode}";

Complete(success, message);

return output;
// Return trimmed output only on real success
return success ? output.Trim() : null;
}
catch (OperationCanceledException)
{
_logger.Log(LogType.Warning, "Process cancelled.");
Complete(false, "Probe cancelled by user");
_logger.Log(LogType.Warning, "Probe was cancelled.");
return null;
}
catch (Exception ex)
{
_logger.Log(LogType.Warning, $"Process failed: {ex.Message}");
var msg = $"Error executing yt-dlp probe: {ex.Message}";
_logger.Log(LogType.Warning, msg);
OnErrorMessage?.Invoke(this, msg);
Complete(false, msg);
return null;
}
}
Expand Down
Loading
Loading