releases/VideoDownloader.Core/Hls/HlsDownloader.cs

261 lines
11 KiB
C#

using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Hls;
/// <summary>
/// HLS 다운로더: 마스터→최고화질 변환, 세그먼트 병렬 다운로드+이어받기,
/// AES-128 복호화, fMP4(#EXT-X-MAP) 처리, 병합, ffmpeg 리먹스.
/// </summary>
public class HlsDownloader
{
private readonly HttpClient _http;
private readonly AppConfig _config;
private readonly ConcurrentDictionary<Uri, byte[]> _keys = new();
public HlsDownloader(HttpClient http, AppConfig config)
{
_http = http;
_config = config;
}
/// <param name="outputBase">확장자 없는 출력 경로. 최종 파일은 outputBase + ".mp4"</param>
public async Task<string> DownloadAsync(
Uri playlistUrl, string outputBase,
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
{
var partsDir = outputBase + ".parts";
Directory.CreateDirectory(partsDir);
string? dir = Path.GetDirectoryName(outputBase);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
bool success = false;
try
{
// 재생목록 해석 (마스터면 최고 화질 변환 따라가기)
var parsed = M3u8Parser.Parse(await _http.GetStringAsync(playlistUrl, ct), playlistUrl);
int level = 0;
while (parsed.Type == PlaylistType.Master)
{
if (parsed.Variants.Count == 0)
throw new InvalidOperationException("마스터 재생목록에 변환이 없습니다.");
var best = parsed.Variants.OrderByDescending(v => v.Bandwidth).First();
parsed = M3u8Parser.Parse(await _http.GetStringAsync(best.Url, ct), best.Url);
if (++level > 5) throw new InvalidOperationException("재생목록 중첩이 너무 깊습니다.");
}
var segs = parsed.Segments;
if (segs.Count == 0) throw new InvalidOperationException("다운로드할 세그먼트가 없습니다.");
// fMP4 초기화 세그먼트
string? initPath = null;
if (parsed.InitSegment != null)
{
initPath = Path.Combine(partsDir, "init.fmp4");
await DownloadWithRetryAsync(parsed.InitSegment, initPath + ".tmp", ct);
File.Move(initPath + ".tmp", initPath, overwrite: true);
}
// 키 사전 로드 (merge 단계에서 동기 복호화)
foreach (var ku in segs.Select(s => s.Key?.KeyUri).Where(u => u != null).Distinct()!)
await GetKeyAsync(ku!, ct);
// 세그먼트 병렬 다운로드 (이어받기)
progress?.Report(new DownloadProgress(0, segs.Count, "downloading"));
int skipped = segs.Count(s =>
File.Exists(SegmentPath(partsDir, s)) && new FileInfo(SegmentPath(partsDir, s)).Length > 0);
var done = new StrongBox<int>(skipped);
var opts = new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, _config.Concurrency), CancellationToken = ct };
await Parallel.ForEachAsync(segs, opts, async (seg, token) =>
{
var path = SegmentPath(partsDir, seg);
if (!(File.Exists(path) && new FileInfo(path).Length > 0))
{
await DownloadWithRetryAsync(seg.Url, path + ".tmp", token);
File.Move(path + ".tmp", path, overwrite: true);
}
var d = Interlocked.Increment(ref done.Value);
progress?.Report(new DownloadProgress(d, segs.Count, "downloading"));
});
// 병합 (init + 세그먼트 순서대로, 필요시 복호화)
progress?.Report(new DownloadProgress(segs.Count, segs.Count, "merging"));
bool isFmp4 = initPath != null;
var merged = Path.GetFullPath(outputBase + ".merged" + (isFmp4 ? ".mp4" : ".ts"));
using (var outFs = File.Create(merged))
{
if (initPath != null && File.Exists(initPath))
outFs.Write(File.ReadAllBytes(initPath));
foreach (var seg in segs.OrderBy(s => s.Index))
{
var data = File.ReadAllBytes(SegmentPath(partsDir, seg));
if (seg.Key != null) data = DecryptSegment(data, seg);
outFs.Write(data);
}
}
var result = await RemuxAsync(merged, outputBase, isFmp4, progress, ct);
success = true;
return result;
}
finally
{
// #6 성공 시 parts 디렉토리 정리 (실패/취소 시 호출자가 정리)
try { if (success && Directory.Exists(partsDir)) Directory.Delete(partsDir, recursive: true); }
catch { }
}
}
private static string SegmentPath(string partsDir, Segment seg) =>
Path.Combine(partsDir, $"{seg.Index:000000}.seg");
// ---- 다운로드 / 재시도 ----
private async Task DownloadWithRetryAsync(Uri url, string path, CancellationToken ct)
{
Exception? last = null;
for (int attempt = 1; attempt <= _config.Retries; attempt++)
{
try
{
using var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
await using var fs = File.Create(path);
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
await stream.CopyToAsync(fs, 65536, ct);
return;
}
catch (OperationCanceledException) { throw; }
catch (Exception e)
{
last = e;
if (attempt == _config.Retries) break;
try { await Task.Delay(Math.Min(1500 * attempt, 10000), ct); } catch { }
}
}
throw last!;
}
// ---- AES-128 ----
private async Task GetKeyAsync(Uri keyUri, CancellationToken ct)
{
if (_keys.ContainsKey(keyUri)) return;
var key = await _http.GetByteArrayAsync(keyUri, ct);
_keys[keyUri] = key;
}
private byte[] DecryptSegment(byte[] data, Segment seg)
{
var key = seg.Key!;
var keyBytes = _keys[key.KeyUri!];
var iv = ResolveIv(key, seg.Sequence);
using var aes = Aes.Create();
aes.Key = keyBytes;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.None;
using var dec = aes.CreateDecryptor();
return dec.TransformFinalBlock(data, 0, data.Length);
}
private static byte[] ResolveIv(KeyInfo key, long seq)
{
var iv = key.Iv;
if (!string.IsNullOrEmpty(iv))
{
var hex = iv.Replace("0x", "").Replace(" ", "");
return Convert.FromHexString(hex.Length % 2 == 1 ? "0" + hex : hex);
}
// IV 미지정 시 시퀀스 번호를 16바이트 big-endian 으로
var b = new byte[16];
var seqBytes = BitConverter.GetBytes(seq); // little-endian on x64
if (BitConverter.IsLittleEndian) Array.Reverse(seqBytes);
Array.Copy(seqBytes, 0, b, 16 - seqBytes.Length, seqBytes.Length);
return b;
}
// ---- 리먹스 ----
private async Task<string> RemuxAsync(
string merged, string outputBase, bool isFmp4,
IProgress<DownloadProgress>? progress, CancellationToken ct)
{
var ffmpeg = ResolveFfmpeg();
if (ffmpeg != null)
{
var final = outputBase + ".mp4";
if (Path.GetFullPath(final) == Path.GetFullPath(merged))
final = outputBase + ".out.mp4";
progress?.Report(new DownloadProgress(0, 0, "remuxing"));
try
{
await RunFfmpegAsync(ffmpeg, merged, final, ct);
if (File.Exists(final) && new FileInfo(final).Length > 0)
{
File.Delete(merged);
return final;
}
throw new InvalidOperationException("ffmpeg 출력 파일이 비었거나 없습니다.");
}
catch (OperationCanceledException) { throw; }
catch
{
// #2 리먹스 실패 → partial mp4 정리 후 예외 전파 (item.Error 로 사용자 노출)
try { if (File.Exists(final)) File.Delete(final); } catch { }
throw;
}
}
// ffmpeg 없음 → 병합본을 그대로(.ts/.mp4)
var keep = outputBase + (isFmp4 ? ".mp4" : ".ts");
File.Move(merged, keep, overwrite: true);
return keep;
}
private string? ResolveFfmpeg()
{
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;
}
// #2 stderr/stdout 파이프를 동시에 드레인(데드락 방지), 종료 보장(좀비 방지), 실패 시 예외 throw
// #10 stdout 드레인 태스크도 관측 — 취소 시 미관측(faulted) 태스크가 되지 않도록 finally 에서 await.
private static async Task RunFfmpegAsync(string ffmpeg, string input, string output, CancellationToken ct)
{
Process? proc = null;
Task<string>? stdoutTask = null;
try
{
var psi = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
foreach (var a in new[] { "-y", "-hide_banner", "-loglevel", "error", "-i", input, "-c", "copy", output })
psi.ArgumentList.Add(a);
proc = Process.Start(psi) ?? throw new InvalidOperationException("ffmpeg 시작 실패");
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); // stdout 도 드레인(데드락 방지)
await proc.WaitForExitAsync(ct);
var stderr = await stderrTask;
if (proc.ExitCode != 0)
throw new InvalidOperationException($"ffmpeg 리먹스 실패(exit {proc.ExitCode}): {stderr.Trim()}");
}
finally
{
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
// #10 드레인 태스크 관측 (취소/OCE/EOF 시 폭발 무시)
try { if (stdoutTask is not null) await stdoutTask; } catch { }
}
}
}