using System.Collections.ObjectModel; using System.Net; using System.Net.Http; using System.Windows.Data; using Microsoft.Web.WebView2.Core; using VideoDownloader.Core.Detection; using VideoDownloader.App.ViewModels; using VideoDownloader.Core.Config; using VideoDownloader.Core.Dash; using VideoDownloader.Core.Hls; using VideoDownloader.Core.Models; using VideoDownloader.Core.Social; using VideoDownloader.Core.Util; namespace VideoDownloader.App.Core; /// 감지 → (HLS: 마스터해석) → 목록(대기) 등록. 다운로드는 사용자가 선택 실행. public class DownloadManager { private readonly AppConfig _config; public AppConfig Config => _config; private readonly Func>> _getCookies; private readonly Action _log; private readonly object _itemsLock = new(); private readonly HashSet _seenMedia = new(); private readonly object _seenLock = new(); private readonly string _reqLogPath; private readonly object _fileLock = new(); // #5/#19 동시 다운로드(항목) 제한 — 런타임 cap 변경 지원(동적 게이트). // 증가는 즉시 Release, 감소는 진행 중 항목이 완료될 때 슬롯을 채우지 않아 자연 수축. private readonly SemaphoreSlim _slot; private int _cap; private int _held; private readonly object _gateLock = new(); // #11 공유 커넥션 풀 — 호출마다 HttpClientHandler 를 new 하지 않아 소켓 고갈 방지. // 쿠키는 컨테이너(프로세스 수준 누적·타사이트 누출) 대신 요청(다운로드) 단위 헤더로 스코프. private static readonly SocketsHttpHandler SharedHandler = new() { UseCookies = false, PooledConnectionLifetime = TimeSpan.FromMinutes(2), PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1), AllowAutoRedirect = true, MaxConnectionsPerServer = 32, }; public ObservableCollection Items { get; } public DownloadManager(AppConfig config, Func>> getCookies, Action log) { _config = config; _getCookies = getCookies; _log = log; _cap = Math.Max(1, config.MaxConcurrentDownloads); _slot = new SemaphoreSlim(_cap); Items = new ObservableCollection(); BindingOperations.EnableCollectionSynchronization(Items, _itemsLock); _reqLogPath = Path.Combine(config.OutputFolder, "requests.log"); try { Directory.CreateDirectory(config.OutputFolder); File.WriteAllText(_reqLogPath, ""); } catch { } } public void AppendAll(string line) { try { lock (_fileLock) { // #8 로테이션 (10MB 초과 시 .1 로 밀고 truncate) try { var fi = new FileInfo(_reqLogPath); if (fi.Exists && fi.Length > 10 * 1024 * 1024) { var bak = _reqLogPath + ".1"; if (File.Exists(bak)) File.Delete(bak); File.Move(_reqLogPath, bak); } } catch { } File.AppendAllText(_reqLogPath, $"{DateTime.Now:HH:mm:ss} {line}{Environment.NewLine}"); } } catch { } } public void LogPanel(string line) => _log(line); public void OnDetected(DetectionContext ctx) => _ = HandleAsync(ctx); private async Task HandleAsync(DetectionContext ctx) { try { var detectedUrl = new Uri(ctx.Url); _log($"[감지] {ctx.Kind} | {ctx.Url}"); // 즉시 중복체크 + 등록 → 목록에 바로 표시 (HLS 해석/쿠키 fetch 를 기다리지 않음). // HLS 는 HlsDownloader 가 다운로드 시 마스터→미디어 를 직접 해석하므로 여기서 미리 해석할 필요 없음. lock (_seenLock) if (!_seenMedia.Add(ctx.Kind + "|" + detectedUrl)) { _log($"[중복] {detectedUrl}"); return; } var title = ctx.Kind == MediaKind.Social && !string.IsNullOrWhiteSpace(ctx.Title) ? CleanSocialTitle(ctx.Title!) : FileNameUtil.DeriveName(detectedUrl); var outputBase = MakeUnique(Path.Combine(_config.OutputFolder, FileNameUtil.Sanitize(title))); var fmt = ctx.Kind switch { MediaKind.Hls => "HLS", MediaKind.Dash => "DASH", MediaKind.Social => string.IsNullOrWhiteSpace(ctx.Tag) ? "SOCIAL" : ctx.Tag!, _ => MediaDetector.MatchFormat(ctx.Url, ctx.ContentType ?? "")?.ToUpperInvariant() ?? "FILE", }; var item = new DownloadItemViewModel(title) { Kind = ctx.Kind, FormatTag = fmt, MediaUrl = detectedUrl, OutputBase = outputBase, PageUrl = ctx.PageUrl, ContentType = ctx.ContentType, }; // 소셜 카드: 화질 프리셋 즉시 채우기(정밀 조회는 카드의 "불러오기" 버튼으로 별도) if (ctx.Kind == MediaKind.Social) { foreach (var p in SocialPresets.All) item.Formats.Add(p); item.SelectedFormat = SocialPresets.DefaultFor(_config.DefaultSocialQuality); } lock (_itemsLock) Items.Insert(0, item); _log($"[등록] {title} | {detectedUrl}"); // HLS: 백그라운드에서 마스터를 한 번 받아 모든 변환(미디어 재생목록) URL 을 seen 으로 마킹 → // 브라우저가 이후 해당 미디어 재생목록을 요청해도 중복 등록되지 않게. if (ctx.Kind == MediaKind.Hls) _ = MarkVariantsSeenAsync(detectedUrl, ctx.PageUrl); } catch (Exception e) { _log($"[오류] {ctx.Url} :: {e}"); } } private async Task MarkVariantsSeenAsync(Uri masterUrl, string? referer) { try { var http = await BuildHttpClientAsync(masterUrl, referer); var parsed = M3u8Parser.Parse(await http.GetStringAsync(masterUrl), masterUrl); if (parsed.Type == PlaylistType.Master) { lock (_seenLock) foreach (var v in parsed.Variants) _seenMedia.Add(MediaKind.Hls + "|" + v.Url); } } catch { } } public async Task StartDownload(DownloadItemViewModel item) { if (item.Started) return; item.Started = true; _ = RunWithSlotAsync(item); } // #5 다운로드 슬롯(동시수) 제한 하에 실행. fire-and-forget 이되 예외 관찰. // #19 설정 변경 시 동시 항목 수를 런타임에 반영. public void UpdateMaxConcurrent(int newMax) { newMax = Math.Max(1, newMax); lock (_gateLock) { int delta = newMax - _cap; _cap = newMax; if (delta > 0) _slot.Release(delta); } } // #5 다운로드 슬롯(동시수) 제한 하에 실행. fire-and-forget 이되 예외 관찰. private async Task RunWithSlotAsync(DownloadItemViewModel item) { await _slot.WaitAsync(); lock (_gateLock) _held++; HttpClient? http = null; try { http = await BuildHttpClientAsync(item.MediaUrl, item.PageUrl); if (item.Kind == MediaKind.Hls) await DownloadHlsAsync(item, http); else if (item.Kind == MediaKind.Dash) await DownloadDashAsync(item, http); else if (item.Kind == MediaKind.Social) await DownloadSocialAsync(item); else await DownloadDirectAsync(item, http); } catch (OperationCanceledException) { /* 다운로드 메서드가 상태 처리 */ } catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); } finally { bool refill; lock (_gateLock) { _held--; refill = _held < _cap; } if (refill) _slot.Release(); try { http?.Dispose(); } catch { } // disposeHandler=false → 공유 핸들러는 유지 } } /// 선택한 항목 재시도 (오류/취소/완료 포함). 진행 중은 무시. public async Task RetryDownload(DownloadItemViewModel item) { if (item.Status is "다운로드 중" or "병합 중" or "리먹스 중") return; item.Started = false; item.Progress = 0; item.Segments = ""; item.Error = null; await StartDownload(item); } /// #1 다운로드 취소 public void Cancel(DownloadItemViewModel item) { try { item.Cts?.Cancel(); } catch { } } /// 소셜 카드 제목 갱신(페이지 타이틀이 나중에 확정될 때). 아직 시작 전이면 출력 파일명도 갱신. public void RenameSocial(Uri url, string title) { if (string.IsNullOrWhiteSpace(title)) return; var clean = CleanSocialTitle(title); lock (_itemsLock) { var it = Items.FirstOrDefault(i => i.Kind == MediaKind.Social && i.MediaUrl == url); if (it == null) return; it.Title = clean; if (!it.Started) it.OutputBase = MakeUnique(Path.Combine(_config.OutputFolder, FileNameUtil.Sanitize(clean))); } } private async Task DownloadHlsAsync(DownloadItemViewModel item, HttpClient http) { var cts = new CancellationTokenSource(); item.Cts = cts; try { item.Status = "다운로드 중"; Directory.CreateDirectory(_config.OutputFolder); var dl = new HlsDownloader(http, _config); var progress = new Progress(p => { item.Segments = p.Detail ?? $"{p.Completed}/{p.Total}"; item.Status = p.Stage switch { "merging" => "병합 중", "remuxing" => "리먹스 중", _ => "다운로드 중" }; if (p.Stage == "downloading") item.Progress = (int)Math.Round(p.Percent); }); var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token); item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}"); } catch (OperationCanceledException) { item.Status = "취소됨"; } catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); } finally { // #6 실패/취소 시 parts 정리 (성공 시엔 엔진이 정리) if (item.Status != "완료") { try { var pd = item.OutputBase + ".parts"; if (Directory.Exists(pd)) Directory.Delete(pd, recursive: true); } catch { } } } } private async Task DownloadDirectAsync(DownloadItemViewModel item, HttpClient http) { var cts = new CancellationTokenSource(); item.Cts = cts; try { item.Status = "다운로드 중"; Directory.CreateDirectory(_config.OutputFolder); var dl = new DirectDownloader(http, _config); var progress = new Progress(p => { item.Segments = p.Detail ?? ""; if (p.OverridePercent >= 0) { item.IsIndeterminate = false; item.Progress = (int)Math.Round(p.OverridePercent); } else item.IsIndeterminate = true; // #9 길이 미상 → 부정확 모드 }); var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token); item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}"); } catch (OperationCanceledException) { item.Status = "취소됨"; } catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); } finally { // #6/#12 임시 .part 정리 try { var part = item.OutputBase + ".part"; if (File.Exists(part)) File.Delete(part); } catch { } } } private async Task DownloadDashAsync(DownloadItemViewModel item, HttpClient http) { var cts = new CancellationTokenSource(); item.Cts = cts; try { item.Status = "다운로드 중"; Directory.CreateDirectory(_config.OutputFolder); var dl = new DashDownloader(http, _config); var progress = new Progress(p => { item.Segments = p.Detail ?? $"{p.Completed}/{p.Total}"; item.Status = p.Stage switch { "merging" => "병합 중", "remuxing" => "리먹스 중", _ => "다운로드 중" }; if (p.Stage == "downloading") item.Progress = (int)Math.Round(p.Percent); }); var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token); item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}"); } catch (OperationCanceledException) { item.Status = "취소됨"; } catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); } finally { if (item.Status != "완료") { try { var pd = item.OutputBase + ".parts"; if (Directory.Exists(pd)) Directory.Delete(pd, recursive: true); } catch { } } } } private async Task DownloadSocialAsync(DownloadItemViewModel item) { var cts = new CancellationTokenSource(); item.Cts = cts; string? cookiesPath = null; try { item.Status = "다운로드 중"; item.IsIndeterminate = true; Directory.CreateDirectory(_config.OutputFolder); cookiesPath = await WriteCookiesAsync(item.MediaUrl); var fmt = item.SelectedFormat ?? SocialPresets.All[0]; var dl = new YtDlpDownloader(_config); var progress = new Progress(p => { item.Status = p.Stage switch { "remuxing" => "리먹스 중", _ => "다운로드 중" }; if (p.OverridePercent >= 0) { item.IsIndeterminate = false; item.Progress = (int)Math.Round(p.OverridePercent); } else item.IsIndeterminate = true; // 길이 미상/병환 단계 → 부정확 모드 if (!string.IsNullOrEmpty(p.Detail)) item.Segments = p.Detail; }); var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, fmt, cookiesPath, progress, cts.Token); item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}"); } catch (OperationCanceledException) { item.Status = "취소됨"; } catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); } finally { // 잔류 임시/fragment 정리(성공 여부 무관). 무관 파일 미삭제를 위해 outputBase 접두로 한정. try { CleanupYtDlpTemp(item.OutputBase); } catch { } try { if (cookiesPath != null && File.Exists(cookiesPath)) File.Delete(cookiesPath); } catch { } } } /// 카드 "화질 불러오기" 버튼용: yt-dlp -J 로 실제 포맷을 조회해 드롭다운을 교체. public async Task FetchFormatsAsync(DownloadItemViewModel item) { if (item.IsFetchingFormats) return; item.IsFetchingFormats = true; string? cookiesPath = null; try { cookiesPath = await WriteCookiesAsync(item.MediaUrl); var dl = new YtDlpDownloader(_config); var fetched = await dl.FetchFormatsAsync(item.MediaUrl, cookiesPath, CancellationToken.None); if (fetched.Count > 0) { var prev = item.SelectedFormat; item.Formats.Clear(); foreach (var f in fetched) item.Formats.Add(f); item.SelectedFormat = item.Formats.FirstOrDefault(f => prev != null && f.Selector == prev.Selector) ?? item.Formats[0]; _log($"화질 {fetched.Count}개 조회됨: {item.Title}"); } else _log("사용 가능한 화질을 조회하지 못했습니다(프리셋 유지)"); } catch (Exception e) { _log($"화질 조회 오류: {e.Message}"); } finally { item.IsFetchingFormats = false; try { if (cookiesPath != null && File.Exists(cookiesPath)) File.Delete(cookiesPath); } catch { } } } /// WebView2 쿠키를 Netscape 파일로 내보냄. UseCookiesForSocial 이고 쿠키가 있을 때만. private async Task WriteCookiesAsync(Uri url) { if (!_config.UseCookiesForSocial) return null; try { var cookies = await _getCookies(url); if (cookies == null || cookies.Count == 0) return null; var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); var records = cookies.Select(c => new CookieRecord( c.Domain ?? url.Host, c.Path ?? "/", c.Name, c.Value, c.IsSecure, c.IsHttpOnly, c.Expires <= epoch ? 0 : new DateTimeOffset(c.Expires).ToUnixTimeSeconds())).ToList(); var path = Path.Combine(Path.GetTempPath(), $"vd_cookies_{Guid.NewGuid():N}.txt"); CookieJarWriter.Write(path, records); return path; } catch { return null; } } private static void CleanupYtDlpTemp(string outputBase) { var dir = Path.GetDirectoryName(outputBase); var name = Path.GetFileName(outputBase); if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) return; foreach (var f in Directory.EnumerateFiles(dir, name + ".*")) { var fn = f; if (fn.EndsWith(".part", StringComparison.OrdinalIgnoreCase) || fn.EndsWith(".ytdl", StringComparison.OrdinalIgnoreCase) || fn.Contains(".part-Frag", StringComparison.OrdinalIgnoreCase)) File.Delete(f); } } private static string CleanSocialTitle(string t) { // 사이트 접미사 제거 foreach (var suffix in new[] { " - YouTube", " | Instagram", " | TikTok", " on Twitter", " / X", " - Facebook" }) if (t.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) { t = t[..^suffix.Length]; break; } return t.Trim(); } private async Task BuildHttpClientAsync(Uri url, string? referer) { var http = new HttpClient(SharedHandler, disposeHandler: false) { Timeout = TimeSpan.FromMinutes(10) }; http.DefaultRequestHeaders.UserAgent.ParseAdd(_config.UserAgent); if (!string.IsNullOrEmpty(referer)) { try { http.DefaultRequestHeaders.Referrer = new Uri(referer); } catch { } } // #11 쿠키를 이 HttpClient(=이 다운로드) 에만 스코프 — 공유 컨테이너의 프로세스 수준 누적/타사이트 누출 방지. // WebView2 가 해당 URL 에 대해 돌려준 쿠키만 Cookie 헤더로 구성(TryAddWithoutValidation: 값 검증 느슨하게). try { var cookies = await _getCookies(url); if (cookies is { Count: > 0 }) { var header = string.Join("; ", cookies.Select(c => $"{c.Name}={c.Value}")); if (!string.IsNullOrEmpty(header)) http.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", header); } } catch { } return http; } private static readonly string[] OutExts = { ".mp4", ".ts", ".webm", ".mkv", ".m4v", ".mov", ".avi", ".m4a", ".mp3", ".ogg", ".opus", ".wav", ".flac" }; private static string MakeUnique(string base_) { var dir = Path.GetDirectoryName(base_) ?? "."; var file = Path.GetFileName(base_); var p = Path.Combine(dir, file); int i = 1; while (OutExts.Any(ext => File.Exists(p + ext)) || Directory.Exists(p + ".parts")) p = Path.Combine(dir, $"{file} ({i++})"); return p; } }