Baseline: WPF/Avalonia/Cli/Core + plans (BROWSER_FEATURES_PLAN v2, MOBILE_PLAN v1)

This commit is contained in:
Yun Chan 2026-08-15 11:59:25 +09:00
commit 21b7c4d8bd
60 changed files with 6581 additions and 0 deletions

View file

@ -0,0 +1,476 @@
using System.Collections.ObjectModel;
using System.Net;
using System.Net.Http;
using System.Windows.Data;
using Microsoft.Web.WebView2.Core;
using VideoDownloader.App.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;
/// <summary>감지 → (HLS: 마스터해석) → 목록(대기) 등록. 다운로드는 사용자가 선택 실행.</summary>
public class DownloadManager
{
private readonly AppConfig _config;
private readonly Func<Uri, Task<List<CoreWebView2Cookie>>> _getCookies;
private readonly Action<string> _log;
private readonly object _itemsLock = new();
private readonly HashSet<string> _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<DownloadItemViewModel> Items { get; }
public DownloadManager(AppConfig config, Func<Uri, Task<List<CoreWebView2Cookie>>> getCookies, Action<string> log)
{
_config = config;
_getCookies = getCookies;
_log = log;
_cap = Math.Max(1, config.MaxConcurrentDownloads);
_slot = new SemaphoreSlim(_cap);
Items = new ObservableCollection<DownloadItemViewModel>();
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 → 공유 핸들러는 유지
}
}
/// <summary>선택한 항목 재시도 (오류/취소/완료 포함). 진행 중은 무시.</summary>
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);
}
/// <summary>#1 다운로드 취소</summary>
public void Cancel(DownloadItemViewModel item)
{
try { item.Cts?.Cancel(); }
catch { }
}
/// <summary>소셜 카드 제목 갱신(페이지 타이틀이 나중에 확정될 때). 아직 시작 전이면 출력 파일명도 갱신.</summary>
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<DownloadProgress>(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<DownloadProgress>(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<DownloadProgress>(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<DownloadProgress>(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 { }
}
}
/// <summary>카드 "화질 불러오기" 버튼용: yt-dlp -J 로 실제 포맷을 조회해 드롭다운을 교체.</summary>
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 { }
}
}
/// <summary>WebView2 쿠키를 Netscape 파일로 내보냄. UseCookiesForSocial 이고 쿠키가 있을 때만.</summary>
private async Task<string?> 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<HttpClient> 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;
}
}