refactor(2): Core Platform abstractions (IProcessRunner/IMediaMuxer + Desktop impls) - Hls/Dash/YtDlp through injection seams, threading contract documented (66 tests GREEN)
This commit is contained in:
parent
9eb988440f
commit
6df9f81b4a
6 changed files with 402 additions and 220 deletions
83
VideoDownloader.Core/Platform/ProcessModels.cs
Normal file
83
VideoDownloader.Core/Platform/ProcessModels.cs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
using System.Text;
|
||||
namespace VideoDownloader.Core.Platform;
|
||||
|
||||
/// <summary>프로세스 실행 명세. EnvPrefix 값은 기존 값 앞에 붙는다(PATH 선두 주입용).</summary>
|
||||
public sealed record ProcessSpec(
|
||||
string ExePath,
|
||||
IReadOnlyList<string> Args,
|
||||
IReadOnlyDictionary<string, string>? EnvPrefix = null);
|
||||
|
||||
/// <summary>프로세스 실행 결과. Stdout/Stderr 은 전체 누적(라인 탭과 별개).</summary>
|
||||
public sealed record ProcessResult(int ExitCode, string Stdout, string Stderr);
|
||||
|
||||
/// <summary>
|
||||
/// 프로세스 실행 추상화(재구성 ② / M0 전제). 모바일(iOS)에선 구현체를 주입하지 않고
|
||||
/// 데스크톱(Win/macOS/Linux)은 DesktopProcessRunner 사용. 단일 프리미티브로
|
||||
/// yt-dlp 라인 스트리밍 · -J 캡처 · ffmpeg 리먹스/DASH 진행 파싱을 모두 커버한다.
|
||||
/// </summary>
|
||||
public interface IProcessRunner
|
||||
{
|
||||
/// <summary>실행. onLine?(stream:"out"|"err", line) 실시간 탭. 취소 시 트리 킬.</summary>
|
||||
Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>데스크톱 구현 — 기존 YtDlpRunner/HlsDownloader 의 검증된 안전 규칙:
|
||||
/// stdout/stderr 동시 드레인(데드락 방지), 취소/실패 시 entireProcessTree 킬, 인자 배열 전달.</summary>
|
||||
public sealed class DesktopProcessRunner : IProcessRunner
|
||||
{
|
||||
public static DesktopProcessRunner Default { get; } = new();
|
||||
|
||||
public async Task<ProcessResult> RunAsync(ProcessSpec spec, Action<string, string>? onLine, CancellationToken ct)
|
||||
{
|
||||
System.Diagnostics.Process? proc = null;
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo(spec.ExePath)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
};
|
||||
foreach (var a in spec.Args) psi.ArgumentList.Add(a);
|
||||
if (spec.EnvPrefix != null)
|
||||
{
|
||||
foreach (var (key, prefix) in spec.EnvPrefix)
|
||||
{
|
||||
var cur = psi.Environment.TryGetValue(key, out var existing) ? (existing ?? "") : "";
|
||||
psi.Environment[key] = prefix + Path.PathSeparator + cur;
|
||||
}
|
||||
}
|
||||
|
||||
proc = System.Diagnostics.Process.Start(psi)
|
||||
?? throw new InvalidOperationException($"{spec.ExePath} 시작 실패");
|
||||
await using var _ = ct.Register(() =>
|
||||
{
|
||||
try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { }
|
||||
});
|
||||
|
||||
var stdout = new System.Text.StringBuilder();
|
||||
var stderr = new System.Text.StringBuilder();
|
||||
var outTask = TapAsync(proc.StandardOutput, "out", stdout, onLine, ct);
|
||||
var errTask = TapAsync(proc.StandardError, "err", stderr, onLine, ct);
|
||||
await proc.WaitForExitAsync(ct);
|
||||
await Task.WhenAll(outTask, errTask);
|
||||
return new ProcessResult(proc.ExitCode, stdout.ToString(), stderr.ToString());
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task TapAsync(StreamReader reader, string stream, StringBuilder acc,
|
||||
Action<string, string>? onLine, CancellationToken ct)
|
||||
{
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync(ct)) != null)
|
||||
{
|
||||
acc.AppendLine(line);
|
||||
if (onLine != null && !string.IsNullOrWhiteSpace(line)) onLine(stream, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue