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; /// /// HLS 다운로더: 마스터→최고화질 변환, 세그먼트 병렬 다운로드+이어받기, /// AES-128 복호화, fMP4(#EXT-X-MAP) 처리, 병합, ffmpeg 리먹스. /// public class HlsDownloader { private readonly HttpClient _http; private readonly AppConfig _config; private readonly ConcurrentDictionary _keys = new(); public HlsDownloader(HttpClient http, AppConfig config) { _http = http; _config = config; } /// 확장자 없는 출력 경로. 최종 파일은 outputBase + ".mp4" public async Task DownloadAsync( Uri playlistUrl, string outputBase, IProgress? 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(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 RemuxAsync( string merged, string outputBase, bool isFmp4, IProgress? 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? 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 { } } } }