111 lines
4.8 KiB
C#
111 lines
4.8 KiB
C#
using System.Net.Http;
|
|
using VideoDownloader.Core.Config;
|
|
using VideoDownloader.Core.Models;
|
|
|
|
namespace VideoDownloader.Core.Platform;
|
|
|
|
/// <summary>
|
|
/// 미디어 병합/리먹스 추상화(재구성 ②). ffmpeg 서브프로세스가 필요한 두 지점
|
|
/// (HLS 세그먼트 리먹스 · DASH ffmpeg-native 다운로드)를 캡슐화한다.
|
|
/// 모바일 호스트는 네이티브 구현으로 대체 주입 가능.
|
|
/// </summary>
|
|
public interface IMediaMuxer
|
|
{
|
|
/// <summary>ffmpeg -c copy 리먹스(HLS .ts→.mp4, fMP4 병합). 실패 시 stderr 포함 예외.</summary>
|
|
Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct);
|
|
|
|
/// <summary>ffmpeg -i <mpd> DASH 네이티브 다운로드. -progress pipe:1 진행률 파싱.</summary>
|
|
Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec,
|
|
HttpClient http, IProgress<DownloadProgress>? progress, CancellationToken ct);
|
|
|
|
/// <summary>ffmpeg 실행 파일 해결(설정 → PATH). 없으면 null.</summary>
|
|
string? ResolveFfmpeg(AppConfig config);
|
|
}
|
|
|
|
/// <summary>데스크톱 구현 — IProcessRunner 기반. 기존 Hls/Dash 다운로더의 인자·진행 파싱을 그대로 보존.</summary>
|
|
public sealed class DesktopMediaMuxer : IMediaMuxer
|
|
{
|
|
public static DesktopMediaMuxer Default { get; } = new(DesktopProcessRunner.Default);
|
|
|
|
private readonly IProcessRunner _runner;
|
|
public DesktopMediaMuxer(IProcessRunner runner) => _runner = runner;
|
|
|
|
public async Task RemuxAsync(string ffmpeg, string input, string output, CancellationToken ct)
|
|
{
|
|
var res = await _runner.RunAsync(new ProcessSpec(ffmpeg,
|
|
new[] { "-y", "-hide_banner", "-loglevel", "error", "-i", input, "-c", "copy", output }), null, ct);
|
|
if (res.ExitCode != 0)
|
|
throw new InvalidOperationException($"ffmpeg 리먹스 실패(exit {res.ExitCode}): {res.Stderr.Trim()}");
|
|
}
|
|
|
|
public async Task DownloadDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec,
|
|
HttpClient http, IProgress<DownloadProgress>? progress, CancellationToken ct)
|
|
{
|
|
var args = new List<string>
|
|
{
|
|
"-y", "-hide_banner", "-loglevel", "error", "-progress", "pipe:1",
|
|
};
|
|
AddFfmpegHttpOptions(args, http);
|
|
args.Add("-i");
|
|
args.Add(mpdUrl.ToString());
|
|
args.Add("-c");
|
|
args.Add("copy");
|
|
args.Add("-movflags");
|
|
args.Add("+faststart");
|
|
args.Add(output);
|
|
|
|
var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, args), (stream, line) =>
|
|
{
|
|
if (stream != "out" || durationSec <= 0) return;
|
|
if (line.StartsWith("out_time_ms=", StringComparison.Ordinal)
|
|
&& long.TryParse(line.AsSpan("out_time_ms=".Length), out var us) && us >= 0)
|
|
{
|
|
var pct = Math.Min(99, us / 1_000_000.0 * 100.0 / durationSec);
|
|
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, $"{us / 1_000_000.0:F0}/{durationSec:F0}s"));
|
|
}
|
|
}, ct);
|
|
|
|
if (res.ExitCode != 0)
|
|
{
|
|
var msg = string.IsNullOrWhiteSpace(res.Stderr) ? $"exit {res.ExitCode}" : res.Stderr.Trim();
|
|
throw new InvalidOperationException($"ffmpeg DASH 다운로드 실패: {msg}");
|
|
}
|
|
}
|
|
|
|
public string? ResolveFfmpeg(AppConfig config)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(config.FfmpegPath) && File.Exists(config.FfmpegPath))
|
|
return config.FfmpegPath;
|
|
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
|
|
foreach (var d in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
|
|
{
|
|
var p = Path.Combine(d.Trim('"'), name);
|
|
if (File.Exists(p)) return p;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>HttpClient 의 UA/Referer/Cookie 를 ffmpeg HTTP AVOption(-i 앞) 으로 전달(토큰 CDN 403 방지).</summary>
|
|
private static void AddFfmpegHttpOptions(List<string> args, HttpClient http)
|
|
{
|
|
var ua = http.DefaultRequestHeaders.UserAgent.ToString();
|
|
if (!string.IsNullOrWhiteSpace(ua)) { args.Add("-user_agent"); args.Add(ua); }
|
|
|
|
if (http.DefaultRequestHeaders.Referrer is { } refUri)
|
|
{
|
|
args.Add("-referer");
|
|
args.Add(refUri.IsAbsoluteUri ? refUri.AbsoluteUri : refUri.ToString());
|
|
}
|
|
|
|
if (http.DefaultRequestHeaders.TryGetValues("Cookie", out var cookies))
|
|
{
|
|
var cookieHeader = string.Join("; ", cookies);
|
|
if (!string.IsNullOrWhiteSpace(cookieHeader))
|
|
{
|
|
args.Add("-headers");
|
|
// ffmpeg -headers: 각 헤더를 \r\n 으로 종료한 전체 값.
|
|
args.Add("Cookie: " + cookieHeader + "\r\n");
|
|
}
|
|
}
|
|
}
|
|
}
|