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:
Yun Chan 2026-08-17 15:54:53 +09:00
parent 5a0ce730b2
commit 17f2f91152
88 changed files with 9463 additions and 1127 deletions

View file

@ -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 접두로 한정.