UI 복잡 버전 234 RED→GREEN 완료(docs/UI_USECASES.md §A~§J): 라이브 테마 전환(DynamicResource 전환+ThemeResolver+HighContrast 테마, 재시작 없음), 디자인 토큰 체계·WCAG 대비 결함 5건 수정, 설정창 카테고리 내비/검색 재구성+AppConfig 40여 속성, 다운로드 관리자(속도/ETA/세그먼트맵/전체 일시정지-이어받기/CSV 내보내기/예약 게이트/중복 확인), 라이브러리·아카이브·작업공간·재생목록·즐겨찾기 편집 뷰, 접근성(자동화 속성 30여 개·커스텀 피어·docs/ACCESSIBILITY.md), 인터랙션(휠 클릭 새 탭·XButton 탐색·F12·영역 캡처·프로필 전환), 상태/알림(토스트·알림센터·온보딩·CrashReportDialog). 보강 테스트 35건+실창 E2E 4건(StaDispatcher 기반 — 프록시 설정이 기시작 핸들러를 건드리는 크래시 실결함 수정 포함) — 453/453 GREEN. 이전 미커밋 작업(CI 워크플로·Mdns·QueueHub·UpdateChecker·AGENTS/CLAUDE·빌드 스크립트) 일괄 포함
This commit is contained in:
parent
5a0ce730b2
commit
17f2f91152
88 changed files with 9463 additions and 1127 deletions
|
|
@ -47,6 +47,53 @@ public class DownloadManager
|
|||
|
||||
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;
|
||||
|
|
@ -59,8 +106,24 @@ public class DownloadManager
|
|||
|
||||
_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
|
||||
|
|
@ -184,6 +247,15 @@ public class DownloadManager
|
|||
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);
|
||||
|
|
@ -205,7 +277,7 @@ public class DownloadManager
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>선택한 항목 재시도 (오류/취소/완료 포함). 진행 중은 무시.</summary>
|
||||
/// <summary>선택한 항목 재시도 (오류/취소/완료 포함). 진행 중은 무시. UC-D09 재시도 횟수 카운트.</summary>
|
||||
public async Task RetryDownload(DownloadItemViewModel item)
|
||||
{
|
||||
if (item.Status is "다운로드 중" or "병합 중" or "리먹스 중") return;
|
||||
|
|
@ -213,6 +285,8 @@ public class DownloadManager
|
|||
item.Progress = 0;
|
||||
item.Segments = "";
|
||||
item.Error = null;
|
||||
item.RetryCount++;
|
||||
item.SegmentMap.Clear();
|
||||
await StartDownload(item);
|
||||
}
|
||||
|
||||
|
|
@ -223,6 +297,40 @@ public class DownloadManager
|
|||
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)
|
||||
{
|
||||
|
|
@ -251,16 +359,18 @@ public class DownloadManager
|
|||
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 = "취소됨"; }
|
||||
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
|
||||
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 != "완료")
|
||||
// #6 실패/취소 시 parts 정리 (성공·일시정지 시엔 이어받기 위해 보존)
|
||||
if (item.Status != "완료" && !item.PausedForResume)
|
||||
{
|
||||
try { var pd = item.OutputBase + ".parts"; if (Directory.Exists(pd)) Directory.Delete(pd, recursive: true); }
|
||||
catch { }
|
||||
|
|
@ -285,17 +395,22 @@ public class DownloadManager
|
|||
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 = "취소됨"; }
|
||||
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
|
||||
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 정리
|
||||
try { var part = item.OutputBase + ".part"; if (File.Exists(part)) File.Delete(part); }
|
||||
catch { }
|
||||
// #6/#12 임시 .part 정리(일시정지 시 이어받기 위해 보존)
|
||||
if (item.Status != "완료" && !item.PausedForResume)
|
||||
{
|
||||
try { var part = item.OutputBase + ".part"; if (File.Exists(part)) File.Delete(part); }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -312,15 +427,17 @@ public class DownloadManager
|
|||
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 = "취소됨"; }
|
||||
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
|
||||
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 != "완료")
|
||||
if (item.Status != "완료" && !item.PausedForResume)
|
||||
{
|
||||
try { var pd = item.OutputBase + ".parts"; if (Directory.Exists(pd)) Directory.Delete(pd, recursive: true); }
|
||||
catch { }
|
||||
|
|
@ -350,14 +467,16 @@ public class DownloadManager
|
|||
item.IsIndeterminate = false;
|
||||
item.Progress = (int)Math.Round(p.OverridePercent);
|
||||
}
|
||||
else item.IsIndeterminate = true; // 길이 미상/병환 단계 → 부정확 모드
|
||||
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 = "취소됨"; }
|
||||
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
|
||||
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 접두로 한정.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue