596 lines
26 KiB
C#
596 lines
26 KiB
C#
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;
|
|
|
|
/// <summary>감지 → (HLS: 마스터해석) → 목록(대기) 등록. 다운로드는 사용자가 선택 실행.</summary>
|
|
public class DownloadManager
|
|
{
|
|
private readonly AppConfig _config;
|
|
public AppConfig Config => _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; }
|
|
|
|
/// <summary>다운로드 완료(§J 토스트 알림용).</summary>
|
|
public event Action<DownloadItemViewModel>? ItemCompleted;
|
|
/// <summary>다운로드 실패(§J 토스트·배너용).</summary>
|
|
public event Action<DownloadItemViewModel>? ItemFailed;
|
|
|
|
/// <summary>UC-D21 예약 게이트 — true 를 돌려줘야 다운로드가 시작된다(DownloadScheduler 연결).</summary>
|
|
public Func<bool>? DownloadGate { get; set; }
|
|
|
|
// ---- §E 속도/ETA 샘플러(항목별 최근 샘플) ----
|
|
private readonly Dictionary<string, (long Bytes, DateTime At)> _speedSamples = new();
|
|
|
|
private void ReportSpeed(DownloadItemViewModel item, DownloadProgress p)
|
|
{
|
|
if (p.ReceivedBytes < 0) return;
|
|
var now = DateTime.UtcNow;
|
|
if (_speedSamples.TryGetValue(item.Id, out var prev) && (now - prev.At).TotalMilliseconds < 400) return;
|
|
double bps = 0;
|
|
if (prev.Bytes > 0 && now > prev.At)
|
|
bps = Math.Max(0, (p.ReceivedBytes - prev.Bytes) / (now - prev.At).TotalSeconds);
|
|
_speedSamples[item.Id] = (p.ReceivedBytes, now);
|
|
System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() =>
|
|
item.UpdateSpeedEta(p.ReceivedBytes, p.TotalBytes, bps));
|
|
}
|
|
|
|
/// <summary>§E 세그먼트 맵 갱신 — 완료 세그먼트 수만큼 앞칸을 채운다.</summary>
|
|
private static void ReportSegmentMap(DownloadItemViewModel item, DownloadProgress p)
|
|
{
|
|
if (p.Total <= 0 || p.Stage != "downloading") return;
|
|
System.Windows.Application.Current?.Dispatcher?.BeginInvoke(() =>
|
|
{
|
|
if (item.SegmentMap.Count != p.Total)
|
|
{
|
|
item.SegmentMap.Clear();
|
|
for (var i = 0; i < p.Total; i++) item.SegmentMap.Add(0);
|
|
}
|
|
for (var i = 0; i < Math.Min(p.Completed, item.SegmentMap.Count); i++)
|
|
if (item.SegmentMap[i] < 1.0) item.SegmentMap[i] = 1.0;
|
|
});
|
|
}
|
|
|
|
private void RaiseCompleted(DownloadItemViewModel item, string finalPath)
|
|
{
|
|
item.FinalFileName = Path.GetFileName(finalPath); // UC-D26 최종 저장 파일명
|
|
item.CompletedAt = DateTime.UtcNow; // UC-D08 자동 치움 판정용
|
|
ItemCompleted?.Invoke(item);
|
|
}
|
|
|
|
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 { }
|
|
|
|
// UC-S13/D22 프록시 설정 — 공유 핸들러는 첫 요청 후 변경 불가(SocketsHttpHandler 제약).
|
|
// 프로세스 최초 1회만 반영하고, 해제는 앱 재시작으로 적용한다(설정창 RestartBanner 안내).
|
|
if (!string.IsNullOrWhiteSpace(config.ProxyServer) && Uri.TryCreate(config.ProxyServer, UriKind.Absolute, out var proxyUri))
|
|
{
|
|
lock (_gateLock)
|
|
{
|
|
if (System.Threading.Interlocked.Exchange(ref _proxyApplied, 1) == 0)
|
|
{
|
|
SharedHandler.Proxy = new System.Net.WebProxy(proxyUri);
|
|
SharedHandler.UseProxy = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static int _proxyApplied;
|
|
|
|
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
|
|
{
|
|
// UC-D21 예약 창 밖이면 창이 열릴 때까지 대기(30초 폴링, 취소 가능)
|
|
while (DownloadGate != null && !DownloadGate())
|
|
{
|
|
item.Status = "예약 대기";
|
|
try { await Task.Delay(TimeSpan.FromSeconds(30), item.Cts?.Token ?? CancellationToken.None); }
|
|
catch (OperationCanceledException) { }
|
|
if (item.Cts?.IsCancellationRequested == true) { item.Status = "취소됨"; return; }
|
|
}
|
|
if (item.Status == "예약 대기") item.Status = "다운로드 중";
|
|
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>선택한 항목 재시도 (오류/취소/완료 포함). 진행 중은 무시. UC-D09 재시도 횟수 카운트.</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;
|
|
item.RetryCount++;
|
|
item.SegmentMap.Clear();
|
|
await StartDownload(item);
|
|
}
|
|
|
|
/// <summary>#1 다운로드 취소</summary>
|
|
public void Cancel(DownloadItemViewModel item)
|
|
{
|
|
try { item.Cts?.Cancel(); }
|
|
catch { }
|
|
}
|
|
|
|
// ---- §E 일시정지/재개: 엔진 취소 + 파트(.part/.parts) 보존 → 재개 시 이어받기 ----
|
|
|
|
/// <summary>항목 일시정지 — 진행 중 항목을 취소하되 임시 파일을 남겨 재개 시 이어받는다.</summary>
|
|
public void Pause(DownloadItemViewModel item)
|
|
{
|
|
if (item.Status is not ("다운로드 중" or "병합 중" or "리먹스 중")) return;
|
|
item.PausedForResume = true;
|
|
item.Status = "일시정지";
|
|
try { item.Cts?.Cancel(); } catch { }
|
|
}
|
|
|
|
/// <summary>일시정지 항목 재개(이어받기).</summary>
|
|
public void Resume(DownloadItemViewModel item)
|
|
{
|
|
if (!item.PausedForResume) return;
|
|
item.PausedForResume = false;
|
|
item.Started = false;
|
|
_ = StartDownload(item);
|
|
}
|
|
|
|
/// <summary>UC-D06 전체 일시정지.</summary>
|
|
public void PauseAll()
|
|
{
|
|
foreach (var it in Items.Where(i => i.Status is "다운로드 중" or "병합 중" or "리먹스 중").ToList())
|
|
Pause(it);
|
|
}
|
|
|
|
/// <summary>UC-D07 전체 재개.</summary>
|
|
public void ResumeAll()
|
|
{
|
|
foreach (var it in Items.Where(i => i.PausedForResume).ToList())
|
|
Resume(it);
|
|
}
|
|
|
|
/// <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);
|
|
ReportSegmentMap(item, p); // UC-D10 세그먼트 맵
|
|
});
|
|
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
|
|
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
|
|
RaiseCompleted(item, final);
|
|
}
|
|
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
|
|
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
|
|
finally
|
|
{
|
|
// #6 실패/취소 시 parts 정리 (성공·일시정지 시엔 이어받기 위해 보존)
|
|
if (item.Status != "완료" && !item.PausedForResume)
|
|
{
|
|
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 길이 미상 → 부정확 모드
|
|
ReportSpeed(item, p); // §E 속도·ETA
|
|
});
|
|
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
|
|
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
|
|
RaiseCompleted(item, final);
|
|
}
|
|
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
|
|
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
|
|
finally
|
|
{
|
|
// #6/#12 임시 .part 정리(일시정지 시 이어받기 위해 보존)
|
|
if (item.Status != "완료" && !item.PausedForResume)
|
|
{
|
|
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);
|
|
ReportSegmentMap(item, p); // UC-D10 세그먼트 맵
|
|
});
|
|
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
|
|
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
|
|
RaiseCompleted(item, final);
|
|
}
|
|
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
|
|
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
|
|
finally
|
|
{
|
|
if (item.Status != "완료" && !item.PausedForResume)
|
|
{
|
|
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;
|
|
ReportSpeed(item, p); // §E 속도·ETA(yt-dlp 가 바이트 보고할 때)
|
|
});
|
|
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, fmt, cookiesPath, progress, cts.Token);
|
|
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
|
|
RaiseCompleted(item, final);
|
|
}
|
|
catch (OperationCanceledException) { item.Status = item.PausedForResume ? "일시정지" : "취소됨"; }
|
|
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); ItemFailed?.Invoke(item); }
|
|
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;
|
|
}
|
|
}
|