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

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
bin/
obj/
out/
publish/
*.user
.vs/
*.binlog
TestResults/
app.log
requests.log

219
BACKLOG.md Normal file
View file

@ -0,0 +1,219 @@
# Video Downloader — 개선 백로그
> **출처**: 2026-07-26 다차원 코드 감사(9개 차원, 원시 63개 → 상위 15 선정). 모두 실제 소스 기반(file:line 검증).
> **사용법**: 완료한 항목은 `- [ ]` → `- [x]` 로 체크.
## 🏆 우선 구현 추천 (TOP 3)
1. **#1 다운로드 취소 버튼** — 멈출 수 없는 치명 UX 결함, 코드에 Cts만 있어 한 줄이면 끝.
2. **#2 ffmpeg stderr 드레인 + Kill** — hang/데드락/좀비 프로세스 안전 결함 + 리먹스 실패 노출.
3. **#5 동시 다운로드 전역 제한(SemaphoreSlim)** — 소켓 고갈(#11)과 직결, 정적 필드 하나짜리 퀵윈.
---
## S 공수 (빠른 승리 — #1~#10)
### [x] 1. 다운로드 취소 버튼이 UI에 없음
- **임팩트**: 높 · **공수**: S · **파일**: `VideoDownloader.App/MainWindow.xaml`, `Core/DownloadManager.cs`
- **문제**: 카드에 받기/재시도/폴더만 있고 취소 없음. `item.Cts` 토큰은 만들지만 `.Cancel()` 호출 0건 → 다운로드를 멈출 방법 자체가 없음.
- **해결**: 카드에 '취소' 버튼 → `item.Cts?.Cancel()`. 진행 중(다운로드/병합/리먹스)일 때만 활성화.
### [x] 2. ffmpeg stderr 미읽기 → 데드락 + 좀비 프로세스 + 리먹스 실패 조용 폭백
- **임팩트**: 높 · **공수**: S · **파일**: `VideoDownloader.Core/Hls/HlsDownloader.cs` (`RunFfmpegAsync`, `RemuxAsync`)
- **문제**: stderr 리다이렉트만 켜두고 안 읽음 → 파이프 버퍼 차면 ffmpeg hang. 취소 시 Kill 없이 좀비. 실패해도 `.ts`로 조용히 넘어감.
- **해결**: `WaitForExitAsync`와 `StandardError.ReadToEndAsync` 병렬 대기(로그 저장), finally에서 `Kill(entireProcessTree)`, exit≠0이면 예외 throw → `item.Error` 표시. partial mp4 정리.
### [x] 3. explorer.exe 인자 인젝션
- **임팩트**: 높 · **공수**: S · **파일**: `VideoDownloader.App/MainWindow.xaml.cs` (CardFolder/OpenFolder)
- **문제**: `Process.Start("explorer.exe", $"...{path}...")` 문자열 인자 직접 전달. OutputFolder(config 평문) 변조 시 `/root,` 스위치로 인자 조작 가능.
- **해결**: ProcessStartInfo 명시 + `Path.GetFullPath` 정규화 + OutputFolder 하위 검증(또는 `UseShellExecute=true`).
### [x] 4. NewWindowRequested 미처리 → 팝업이 앱 통제 이탈
- **임팩트**: 높 · **공수**: S · **파일**: `VideoDownloader.App/MainWindow.xaml.cs` (`InitializeBrowserAsync`)
- **문제**: `target=_blank`/`window.open()` 팝업이 별도 창 → 감지/쿠키 안 타서 팝업 안 영상은 대기열에 안 잡힘.
- **해결**: `cv.NewWindowRequested`에서 현재 WebView2로 강제 라우팅(`e.NewWindow = (CoreWebView2)s; e.Handled = true`).
### [x] 5. 동시 다운로드 수 전역 제한 없음
- **임팩트**: 높 · **공수**: S · **파일**: `VideoDownloader.App/Core/DownloadManager.cs` (`StartDownload`)
- **문제**: StartDownload가 fire-and-forget. N개 누르면 N × Concurrency개 TCP 동시 요청 폭증 → 소켓 고갈(#11) 가속.
- **해결**: `static SemaphoreSlim(MaxConcurrentDownloads)` 도입, StartDownload에서 WaitAsync/Release. 세그먼트 단위 Concurrency와 분리해 AppConfig에 각각 설정.
### [x] 6. .parts 디렉토리 정리 누락 → 디스크 누수 + 이름 cascade
- **임팩트**: 높 · **공수**: S · **파일**: `VideoDownloader.Core/Hls/HlsDownloader.cs`, `Core/DownloadManager.cs` (`MakeUnique`)
- **문제**: 완료/실패/취소 어느 경로든 partsDir 안 지움 → 수천 세그먼트 영구 축적. 잔류 `.parts`가 `MakeUnique` 밀어내 `(1)(2)...` 접미 연쇄.
- **해결**: 성공 시 `Directory.Delete(partsDir, recursive)`. 실패/취소 시에도 정리(이어받기 토글 옵션).
### [x] 7. 전역 키보드 단축키 전무
- **임팩트**: 높 · **공수**: S · **파일**: `VideoDownloader.App/MainWindow.xaml`
- **문제**: Ctrl+L(주소), F5/Ctrl+R(새로고침), Esc(정지), Alt+←/→(뒤로/앞으로), Ctrl+F(검색), Ctrl+±/0(줌) 어느 것도 안 됨.
- **해결**: `Window.InputBindings`에 KeyBinding 추가.
### [x] 8. requests.log 무한 증가 (로테이션 전무)
- **임팩트**: 높 · **공수**: S · **파일**: `VideoDownloader.App/Core/DownloadManager.cs` (`AppendAll`, `OnResponseReceived`)
- **문제**: 모든 응답(이미지/광고/분석 포함) 기록, 크기 제한 없음 → 세션당 GB 가능, OutputFolder 디스크 채움.
- **해결**: 10MB 임계 롤오버 + truncate. 관심 없는 content-type(status≥300/image/font/css/js/beacon) 스킵. 설정 토글로 끄기.
### [x] 9. 직링크 Content-Length 미상 시 진행률 0% 고정 (멈춤 오인)
- **임팩트**: 높 · **공수**: S · **파일**: `VideoDownloader.Core/Hls/DirectDownloader.cs`, `Models/DownloadProgress.cs`, `MainWindow.xaml`
- **문제**: chunked 응답에서 pct=0 → ProgressBar 내내 0%. Segments엔 '1.2 MB'만 떠 멈춘 것처럼 보임.
- **해결**: 길이 미상(OverridePercent=-1) 시 `IsIndeterminate=true`, Detail(바이트)은 계속 표시.
### [x] 10. 다운로드 실패 원인(Error)이 UI에 안 보임
- **임팩트**: 높 · **공수**: S · **파일**: `VideoDownloader.App/MainWindow.xaml` (카드 템플릿), `ViewModels/DownloadItemViewModel.cs`
- **문제**: `item.Error`는 채워지는데 카드엔 Status만 바인딩 → '오류' 단어만. '404'/'디스크 가득' 등 원인은 app.log 열어야 앎. `ErrorBrush`도 미사용.
- **해결**: 카드에 `{Binding Error}` TextBlock(ErrorBrush, Status=='오류'일 때만 노출) + 실패 카드 보더/전경색 DataTrigger.
---
## M 공수 (#11~#15)
### [x] 11. 호출마다 new HttpClient + Dispose 안 함 (소켓 고갈)
- **임팩트**: 높 · **공수**: M · **파일**: `VideoDownloader.App/Core/DownloadManager.cs` (`BuildHttpClientAsync`)
- **문제**: 매 호출마다 새 HttpClient(handler+client). 항목당 2개. TIME_WAIT ~240초 점유 → 포트 고갈.
- **해결**: 앱 수명 단일 공유 HttpClient(SocketsHttpHandler + PooledConnectionLifetime). 요청별 헤더(쿠키/Referer/UA)는 HttpRequestMessage로.
### [x] 12. 직링크에 재시도·이어받기·임시파일 전무
- **임팩트**: 높 · **공수**: M · **파일**: `VideoDownloader.Core/Hls/DirectDownloader.cs`
- **문제**: GetAsync 1회로 끝. 드롭 시 0바이트부터 재시작. `File.Create(최종이름)` 직접 쓰기 → 중단 시 손상 파일이 최종명으로 남음.
- **해결**: `.part` 임시파일 + `Range: bytes=written-` 이어받기 + 재시도 루프(4xx 영구 오류 제외, Retry-After) + 완료 시 `File.Move`.
### [x] 13. WebView2 기본 다운로드가 대기열/OutputFolder 우회
- **임팩트**: 높 · **공수**: M · **파일**: `VideoDownloader.App/MainWindow.xaml.cs` (`InitializeBrowserAsync`)
- **문제**: 페이지 내 `a[download]` 링크 클릭 시 WebView2 자체 다운로더 → 사용자 기본 폴더. `DownloadStarting` 구독 없음 → 앱 대기열/카드에 안 잡힘.
- **해결**: `cv.DownloadStarting`에서 ResultFilePath를 OutputFolder로 강제 또는 Cancel 후 대기열로 라우팅.
### [x] 14. 글로벌 예외 핸들러/크래시 리포트 부재
- **임팩트**: 높 · **공수**: M · **파일**: `VideoDownloader.App/App.xaml.cs`
- **문제**: `App.xaml.cs` 빈 클래스. `DispatcherUnhandledException`/`TaskScheduler.UnobservedTaskException` 없음 → fire-and-forget 예외 조용 삼켜짐, 크래시 시 '작동 중지'만.
- **해결**: 세 핸들러 연결 → app.log에 스택 기록 + 요약 메시지. fire-and-forget에 `ContinueWith(OnlyOnFaulted)` 관찰.
### [x] 15. 감지 신뢰성: image/* 위장·청크·후보 미등록이 미디어 누락으로
- **임팩트**: 높 · **공수**: M · **파일**: `VideoDownloader.App/Detection/MediaDetector.cs`, `MainWindow.xaml.cs` (`OnResponseReceived`)
- **문제**: (a) `MatchFormat`에 image/* 분기 없음 → tvchak식 `play.png`+`image/png` 위장 mp4/TS 100% 누락. (b) chunked 응답은 크기 가드 통과 → 토큰 만료 HTML 에러 페이지도 mp4로 오탐. (c) `.mpd`/`.m4s`/octet-stream 후보는 로그만 찍고 항목 등록 안 됨.
- **해결**: 다운로드 첫 GET 시 magic byte 검증(MP4 ftyp / TS 0x47 / WebM 0x1A45DFA3 / MP3), MatchFormat에 image/*+대형 분기, 후보도 OnDetected 전달, chunked는 사후 길이 재검증.
---
## 메모
- S 공수 10개(#1~#10)는 반나절 안에 일괄 처리 가능.
- #5와 #11은 함께 잡으면 시너지(소켓 고갈 근본 해결).
- #15는 감지 신뢰성의 핵심 — 이 사이트(tvchak 계열 image/png 위장)에 직결.
---
## 팔로업 (2026-07-26 완료)
- [x] **라이트 테마 토글** — `Themes/Shared.xaml`(스타일) + `Dark/Light.xaml`(색). 설정 체크박스 + 변경 시 재시작. DWM 타이틀바 색 연동.
- [x] **MaxConcurrentDownloads UI 노출** — 설정에 "최대 동시 항목" 필드. (다음 시작부터 적용)
- [x] **DASH(.mpd) 지원** — `Core/Dash/` (DashParser/DashDownloader). 클리어 콘텐츠만. 샘플 MPD 파싱 검증 통과.
- [x] **보더리스 윈도우** — WindowChrome + Fluent 캡션버튼 + 툴바 전체 드래그 + Win11 둥근 모서리.
## 2차 팔로업 (2026-07-27 완료)
- [x] **DASH A/V 통합 + ffmpeg-native** — 수동 세그먼트 병합(fMP4)이 깨지기 쉬워 `ffmpeg -i <mpd>` 로 위임. DashParser는 DRM 사전검증/진행률(duration)용. BBB 공개스트림 종단간 검증(h264+aac mp4 생성).
- [x] **감지 오탐 감소** — GIF 트래킹 픽셀이 TS(0x47='G')로 오탐되던 것 수정. ProbeFormat이 이미지 매직바이트(GIF/PNG/JPEG/WebP) 제외.
- [x] **MaxConcurrentDownloads 런타임 반영** — 동적 게이트(증가=즉시 Release, 감소=완료 시 미충전으로 자연 수축). 설정 저장 시 즉시 적용.
- [x] **라이트 테마 시각 점검** — 팔레트 대비 건전(어두운 텍스트 #1B1C24 / 밝은 배경).
- [x] **주소창 자동완성/제안** — 히스토리 접두사/부분일치 드롭다운 + 인라인 자동완성(Tab/→ 확정, ↑↓ 탐색, Enter 이동, 클릭 이동).
- [x] **인스타/blob·Range 영상 감지** — 206 응답을 Content-Range 전체크기로 판정(청크가 작아 skip되던 것). IG 실계정 테스트는 불가(로그인/지역).
- [x] **IndeterminateBar 스토리보드 예외 수정** — TranslateTransform 의 x:Name(템플릿 namescope 미등록) → Border 이름 + RenderTransform 속성경로로 타겟.
## 3차 팔로업 (2026-07-29 완료) — 쿠키/YouTube + 감사 결함 일괄 수정
> 9개 차원 다차원 감사(24 에이전트 워크플로우, 원시 42개 → 확정 11개 결함) 기반. 사용자 제보(YouTube 다운로드 시 yt-dlp 가 쿠키 파일 거부)에서 시작.
### 치명 — YouTube/쿠키 (사용자 제보 직결)
- [x] **쿠키 파일 UTF-8 BOM → yt-dlp 거부** — `CookieJarWriter` 가 `Encoding.UTF8`(BOM 포함) 로 써 첫 줄 헤더 `# Netscape HTTP Cookie File` 인식이 깨짐 → "does not look like a Netscape format cookies file" + "invalid length 1". `new UTF8Encoding(false)` 로 BOM 제거. HttpOnly 쿠키에 `#HttpOnly_` 접두 추가, 값 내 tab/CRLF sanitize. (`Core/Social/CookieJarWriter.cs`)
- [x] **YouTube player_client 오버라이드가 yt-dlp 기본값보다 못했음** — 하드코딩 `android_vr,android,web_sns,web` 이 2026년엔 대부분 무효: `android`/`ios` 는 GVS/Player **PO 토큰 필수 + 쿠키 미지원**, `web` 도 PO 토큰 없으면 SABR 만, `web_sns` 는 yt-dlp 에 **존재하지 않는 클라이언트**. → 오버라이드를 비우면 yt-dlp 공식 기본값(`visionos,android_vr,web`, 유지보수자 갱신) 사용. 고급 설정 필드 `YoutubePlayerClients`(설정 UI)로 보존. (`Core/Social/YtDlpDownloader.cs`, `Core/Config/AppConfig.cs`, `SettingsWindow`)
### 백로그 "완료" 표기 중 실제 미구현이었던 것
- [x] **#12 Retry-After 미적용** — 백로그엔 "Retry-After 적용" 이라 적었으나 소스엔 한 줄도 없었음. `DirectDownloader` 가 429/503 시 서버 `Retry-After`(최대 60s 캡) 를 존중하도록 수정. (`Core/Hls/DirectDownloader.cs`)
- [x] **#15(b) chunked video/* 사후 길이 재검증 누락** — Content-Length 없는 video/* 응답이 크기 가드를 통과해 토큰 만료 HTML 오류 페이지가 mp4 로 오탐. chunked video/* 를 magic-byte probe 로 재검증(정상 MP4 는 ftyp box 로 통과). (`MainWindow.xaml.cs`)
### 코드 헬스 (감사 확정)
- [x] **공유 CookieContainer 프로세스 수준 누적** — `SharedHandler` 의 컨테이너에 매 다운로드마다 쿠키가 누적/타사이트 누출. `UseCookies=false` + 요청(다운로드) 단위 `Cookie` 헤더로 스코프. (`Core/DownloadManager.cs`)
- [x] **DASH ffmpeg 헤더 누락** — `ffmpeg -i <mpd>` 가 UA/Referer/쿠키 없이 기본 UA 로 받아 토큰·Referer 인증 CDN(maccms 계열) 에서 403. `-user_agent`/`-referer`/`-headers` 를 `-i` 앞에 전달. (`Core/Dash/DashDownloader.cs`)
- [x] **DashParser $Time$ → 세그먼트 번호 오탐** — `$Time$` 를 번호로 치환하던 것(ISO/IEC 23009-1 위반) 을 미디어 시작시각(t/d 누적) 으로 수정. (`Core/Dash/DashParser.cs`)
- [x] **DashParser r=-1 → 세그먼트 0개** — SegmentTimeline `r="-1"`(Period 끝까지 반복) 을 `count=r+1=0` 으로 처리해 표현이 통째로 사라지던 것 수정. (`Core/Dash/DashParser.cs`)
- [x] **HlsDownloader stdout 드레인 태스크 미관측** — 취소 시 `_ =` 폐기 태스크가 미관측 상태로 남던 것을 finally 에서 await. (`Core/Hls/HlsDownloader.cs`)
- [x] **BrowserStore 조용 예외 삼킴** — 즐겨찾기/히스토리 저장·로드 실패를 완전 무시(조용 데이터 손실) 하던 것을 `browserstore.log` 기록 + 손상 파일 `.corrupt.bak` 백업. (`Browser/BrowserStore.cs`)
### 검증
- `dotnet build` 0 오류 / 0 경고.
- 셀프테스트(임시 `--selftest`): 쿠키파일 BOM 없음·HttpOnly 접두·Netscape 헤더 + DashParser r=-1(5 세그먼트)/`$Time$(0,8000)` 8/8 PASS.
- 실제 공개 DASH(BBB) 매니페스트 파싱 정상(VIDEO/AUDIO 각 159 세그먼트) — `$Number$` 경로 회귀 없음.
## 4차 팔로업 (2026-07-29 완료) — 브라우저 프로필 + Private 모드 + 범용 패널
### 브라우저 데이터 보존/관리 (사용자 요청)
- [x] **WebView2 영속 프로필** — 기본(exe 디렉터리 내) user data 폴더는 빌드/재배포 시 날아가 로그인이 풀렸음. `%AppData%/VideoDownloader/webview` 로 명시(`CoreWebView2Environment.CreateAsync`) → 쿠키·세션·localStorage·캐시가 앱/빌드 재시작에도 보존. (`MainWindow.xaml.cs`)
- [x] **Private(InPrivate) 모드** — `CoreWebView2ControllerOptions.IsInPrivateModeEnabled` 로 Edge InPrivate 세션(디스크에 아무것도 저장 안 함). 툴바 토글 버튼(활성 시 강조) → 확인 → `AppConfig.PrivateMode` 저장 → 재시작 적용(테마와 동일 패턴).
- [x] **브라우저 데이터 관리** — 설정창에 프로필 경로 표시 + "삭제" 버튼(`CoreWebView2Profile.ClearBrowsingDataAsync` → 쿠키·DOM 저장소·캐시 정리). (`SettingsWindow`)
### UI (사용자 요청)
- [x] **범용 우측 패널** — 다운로드 컬럼을 접기/펼치기(헤더 토글, 접힘 시 좁은 스트립) + GridSplitter 폭 리사이즈(280~900) + `RightPanelWidth`/`RightPanelCollapsed` 저장·복원. (`MainWindow.xaml/.cs`)
- [x] **진행률 바 미작동 수정** — `AccentProgressBar` 템플릿에 `PART_Track` 이름이 없어 WPF 가 인디케이터 폭을 계산 못 함(바가 안 채워짐). `x:Name="PART_Track"` 추가. (`Themes/Shared.xaml`)
### 검증
- `dotnet build` 0 오류 / 0 경고. 앱 시작 스모크 테스트(새 WebView2 초기화 경로) 7s 생존 확인 — 크래시 없음.
## 5차 팔로업 (2026-07-29 완료) — YouTube n 챌린지(EJS) 다운로드 실패
> 사용자 제보: YouTube 영상 다운로드 시 yt-dlp 가 `n challenge solving failed` + `Only images are available for download` 로 MP4 실패(exit 1).
### 치명 — YouTube (사용자 제보 직결)
- [x] **YouTube n 챌린지 해석 미설정 → 포맷 드랍/스로틀** — YouTube 가 플레이어 응답의 "n" 서명 스로틀 파라미터를 JS 로 난독화. yt-dlp 는 이를 풀기 위해 JS 런타임(Deno) 으로 실행되는 EJS(External JS Solver) 챌린지 솔버가 필요하며, 없으면 비디오 포맷이 통째로 드랍(이미지만 남) 되거나 살아남아도 수백 KiB/s 로 심하게 스로틀됨. `BuildArgs`(다운로드)·`FetchFormatsAsync`(정밀 화질 드롭다운) 양쪽에 `--remote-components ejs:github`(yt-dlp 공식 권장, https://github.com/yt-dlp/yt-dlp/wiki/EJS) 추가. Deno 는 PATH 에 있으면 자동 감지(환경에 2.8.1 설치됨). 솔버 스크립트는 최초 1회만 GitHub 에서 받아 캐싱. (`Core/Social/YtDlpDownloader.cs`)
- **정합성 주의**: `--remote-components` 는 yt-dlp 2025년 중반(EJS 도입) 이후 버전에서만 인식. 본 프로젝트는 yt-dlp 를 `releases/latest` 에서 자동 설치하므로 사실상 항상 지원; 단 사용자가 `YtDlpPath`(설정) 로 구버전을 수동 지정한 경우에만 "unrecognized arguments" 회귀 가능.
### 검증
- 실제 동영상(`EjSqcVC1qwk`) 종단간 테스트: `--remote-components` 없으면 다운로드는 시작되나 `992KiB/s → Unknown B/s` 스로틀; 플래그 추가 시 `13~18 MiB/s` 정상 속도 + mp4 병합(EXIT=0). `-F` 포맷 정상 노출.
- `dotnet build` 0 오류 / 0 경고.
## 6차 팔로업 (2026-07-29 완료) — YouTube 안티봇 실패 시 yt-dlp 자동 갱신/재시도
> 사용자 요청: YouTube 가 난독화를 바꿀 때마다 수동 대응하지 않고 "자동으로 맞춰지게". yt-dlp 유지보수자가 보통 몇 시간~며칠 내 대응 릴리스를 내보내므로, 최신화만으로 대부분 복구됨에 착안.
### 기능
- [x] **안티봇 실패 감지 → yt-dlp 최신화 → 1회 재시도** — `DownloadAsync` 에서 yt-dlp 종료코드≠0 일 때 stderr 에서 안티봇 징후(`n challenge`/`nsig`/`Only images are available`/`PO Token`/`po_token`/`Requested format is not available`/`Unable to extract`) 를 감지하면: (1) 설치 버전 vs GitHub Releases 최신 태그 비교, (2) 새 버전이 있을 때만 `releases/latest` 에서 강제 재다운로드(번들 경로) 후 동일 인자로 1회 재시도. 진행 중 카드에 "yt-dlp 갱신 확인 중 / x → y 갱신 후 재시도" 표시. (`Core/Social/YtDlpDownloader.cs`)
- [x] **버전 비교로 불필요 재다운로드 방지** — 이미 최신인데도 실패하면 갱신 생략하고 에러 메시지에 "YouTube가 방금 바뀌었을 수 있음(잠시 후 재시도 권장)" 부연. (`Core/Social/YtDlpRunner.cs` — `GetVersionAsync`/`GetLatestVersionAsync`)
- [x] **설정 토글** — `AppConfig.AutoUpdateYtDlp`(기본 true). 설정창 "yt-dlp 자동 다운로드" 옆 체크박스로 노출.
### 검증
- 버전 형식 일치 확인: 설치 `2026.03.17` vs GitHub 최신 `2026.07.04` (둘 다 `YYYY.MM.DD` → 문자열 비교 정확). 현재 설치본이 최신보다 구버전이므로, 실패 시 `2026.07.04` 로 자동 갱신 후 재시도 동작 확인 가능.
- 재시도 인자는 `--remote-components ejs:github` 포함(5차) 그대로 재사용 → EJS 솔버 + 바이너리 갱신 이중 방어.
- `dotnet build`(전체 솔루션) 0 오류 / 0 경고.
## 7차 팔로업 (2026-07-29 완료) — Deno(JS 런타임) 자동 설치로 배포 환경 EJS 보장
> 사용자 요청(연장): "Deno도 없으면 자동 설치". 5차의 `--remote-components ejs:github` 는 JS 런타임(Deno) 이 있어야 동작 — 개발 머신엔 Deno 가 있지만 배포 PC엔 없으면 EJS 가 안 풀려 YouTube 가 막힘.
### 기능
- [x] **DenoRunner 자동 설치** — `Resolve`(설정→번들→PATH)·`EnsureInstalledAsync`·`DownloadAsync`(GitHub `denoland/deno` Releases zip → `deno.exe` 단일 엔트리 추출). yt-dlp 와 동일 `%AppData%/VideoDownloader/tools` 공유. OS/아키텍처별 에셋(Windows x64 / macOS·Linux arm64+x64). (`Core/Social/DenoRunner.cs`)
- [x] **PATH 주입(플래그 의존 제거)** — `YtDlpRunner.RunAsync`/`RunCaptureAsync` 가 yt-dlp 자식 프로세스 PATH 앞에 ToolsDir 를 추가 → 번들 `deno.exe` 를 yt-dlp 가 자동 발견. `--js-runtimes deno:<경로>` 플래그의 Windows 경로 콜론 파싱 엣지케이스를 피하는 가장 견고한 방식(시스템 PATH 불변, 자식 프로세스에만 적용). (`Core/Social/YtDlpRunner.cs`)
- [x] **연결** — `DownloadAsync` 시작 시 Deno 를 ensure(best-effort, 진행률 표시). Deno 누락은 치명 오류가 아니므로(yt-dlp 가 경고 후 진행) 결과 무시. `AppConfig.DenoPath`·`AutoInstallDeno`(기본 true) + 설정 토글.
- **3중 방어 완성**: (1) EJS 솔버 `--remote-components ejs:github`(5차) + (2) yt-dlp 자동 갱신(6차) + (3) Deno 자동 설치(7차).
### 검증
- **Deno 없는 환경 시뮬레이션 종단 테스트**: 시스템 deno(npm) 를 PATH에서 제외하고 번들 `deno.exe`(/tmp/vdtools) 만 노출한 상태에서 `yt-dlp --remote-components ejs:github` 실행 → `which deno` = 번들만, 다운로드 **15~124 MiB/s 풀스피드 + 경고 0건 + EXIT=0**(mp4 병합). PATH 주입으로 번들 Deno 가 n 챌린지 해석 확인.
- Deno 다운로드/추출: zip(42MB) → `deno.exe`(97MB) 단일 파일, v2.9.4 정상 동작 확인.
- `dotnet build`(전체 솔루션) 0 오류 / 0 경고.
## 8차 팔로업 (2026-07-29 완료) — 배포 파이프라인 + Avalonia 크로스플랫폼 이관 착수
> 사용자 요청: "배포도 신경써보자" — Windows 다중 아키텍처 정식 설치자 + Avalonia UI 재작성 착수(맥/리눅스 지원 목표). 계획 승인 후 2단계로 실행.
### Phase 1 — Windows Inno Setup 다중 아키텍처 설치자 (완전 완료)
- [x] **`VideoDownloader.App.csproj` 멀티아키텍처/self-contained** — `<Platforms>x64;arm64</Platforms>`, `<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>`, `SelfContained=true`(폴더, single-file/trim 아님 — WPF+트리밍 위험 회피). `PlatformTarget` 하드코딩 제거(RID에서 파생).
- [x] **`build/publish.ps1`** — x64/arm64 루프: `dotnet publish -c Release -r <rid> -p:Platform=<arch> --self-contained` 후 ISCC(`/DARCH=`)로 `out/VideoDownloader-Setup-<arch>.exe` 생성. ISCC 위치 자동 해결(기본경로/PATH/LocalAppData 후보).
- [x] **`build/installer.iss`** — 단일 스크립트 2회 컴파일. 아키텍처별 고유 AppId GUID; `ArchitecturesAllowed` 게이트(arm64 전용 / x64=x64compatible+arm64폴백); per-user 설치(`PrivilegesRequired=lowest`); 시작메뉴+데스크톱 바로가기+제거자; 한국어 기본(`Korean.isl`)+영어(`Default.isl`).
- **Inno Setup**: winget `JRSoftware.InnoSetup`(6.7.3, per-user) 설치. 영어는 `Languages/English.isl`이 아니라 루트 `Default.isl`이 기본(트랩).
### Phase 1 검증
- **두 설치자 생성**: `out/VideoDownloader-Setup-x64.exe`(49.1 MB), `out/VideoDownloader-Setup-arm64.exe`(43.8 MB). 둘 다 유효 PE(MZ).
- **arm64 publish 성공** — WPF arm64(self-contained) 첫 빌드, 이슈 없음(예상 리스크 미발현). yt-dlp 네이티브 arm64 빌드가 없어 arm64 Win11에선 x64 에뮬레이션(WoW64)으로 동작(릴리스 노트 명시 필요).
- `publish.ps1` exit 0(두 아키텍처 publish+ISCC 전 단계).
### Phase 2 — Avalonia 크로스플랫폼 UI (기반 + Gate #0, 로드맵 포함)
- [x] **`VideoDownloader.Avalonia`(신규, net8.0)** — WPF 앱은 유지한 채 병행. Core 참조(엔진 공유). `VideoDownloader.slnx` 등록.
- [x] **Gate #0 최소 동작 UI** — `MainWindow.axaml`/`MainViewModel`(INPC) + `RelayCommand`. URL 붙여넣기 → 유형 판별(소셜/HLS/DASH/직링크) → Core 다운로더 **직접** 호출(DownloadManager의 WebView2 쿠키 결합 회피), 진행률/로그 표시. 소셜은 방금 만든 자동 복구(6차)·Deno 자동설치(7차) 로직도 그대로 작동.
- **Gate #1(관문) = 크로스플랫폼 브라우저 컴포넌트**: 현재 앱의 심장은 WebView2의 응답 가로채기+쿠키. 추천 = **CefGlue.Avalonia**(`OnResourceResponse`↔`WebResourceResponseReceived`, `CefCookieManager`↔쿠키, 거의 1:1). 단, CEF가 자체 Chromium 번들(~150-200MB/OS) + 성숙도/Linux 리스크 → **3OS 프로토타입 통과 전 본 이관 착수 금지**(로드맵 1순위). 차선 CefNet, 최후 DotNetBrowser(상용).
### Phase 2 검증
- `dotnet build` 0 오류/0 경고(Avalonia 12.1.0 + Core net8.0). (컴파일드 바인딩 → 코드비하인드 DataContext이므로 `x:CompileBindings="False"`; `Items`→`ItemsSource`; `Watermark`→`PlaceholderText`.)
- **스모크 테스트**: `dotnet run` → 창 정상 시작, 10초 생존(크래시 없음, 빈 로그) → 크로스플랫폼 빌드 + Core 재사용 + MVVM 바인딩 입증.
### 이관 로드맵(의존 순)
1. Gate #1 브라우저 3OS 프로토타입(CEF) — 통과 전 3~8 불가.
2. 공유 리팩터: `App/Detection/*`(순수C#) Core 이동; `ICookieProvider` 중립 추상화; `DownloadManager` 공유화(생성자→`ICookieProvider`); `BindingOperations.EnableCollectionSynchronization`→Avalonia 디스패처; `IAppPaths` OS별 경로.
3~8. 브라우저+감지, 설정, 테마(Avalonia Styles 재작성), 보더리스 윈도우(`ExtendClientAreaToDecorationsHint`), ViewModels, 북마크/히스토리.
9. Mac/Linux 패키징(`.app` 노타라이제이션, `.AppImage`/`.deb`); `YtDlpRunner`/`DenoRunner` OS/arch별 바이너리 확인. 10. 패리티 후 WPF 은퇴.
- **참고**: 저장소가 git 아님 → Phase 2 병행 작업 격리 위해 `git init` 권장.

262
BROWSER_FEATURES_PLAN.md Normal file
View file

@ -0,0 +1,262 @@
# Video Downloader — 브라우저 기능 강화 계획서 (v2)
> **목표**: 이 앱에 내장된 WebView2 브라우저에 일반적인 데스크톱 브라우저의 **핵심 사용자 기능**을 심어, 단순 "영상 재생 → 감지" 용도를 넘어 **일상적으로 쓸 수 있는 브라우저**로 만든다.
>
> **작성일**: 2026-08-15 (v2 — 2차 심층 조사 반영) · **대상**: `VideoDownloader.App` (WPF + WebView2 1.0.4078.44)
>
> **조사 방법론**:
> - 1차(2026-08-13): Chrome/Firefox/Zen 3개 브라우저 단일 조사 → v1 초안
> - **2차(2026-08-15): 5개 전문 조사팀 병렬 심층 조사** — ① Chrome+Edge ② Firefox+Vivaldi ③ Zen+Arc ④ Brave+Opera+Safari+기타(Floorp/Orion/Sidekick/Comet) ⑤ **WebView2 API 검증팀(Microsoft Learn 팩트체크)**. 총 80여 회 웹 검색 + 공식 문서 직접 열람.
>
> **확장 계획**: 모바일 앱 + PC↔모바일 연동은 **`MOBILE_PLAN.md`** 로 분리 (2026-08-15, 4개 조사팀 심층 조사 기반 — MAUI 10 컴패니언 앱, LAN QR 페어링, Core 엔진 재사용 검증 포함).
---
## ⚠️ v1 → v2 주요 수정 사항 (2차 조사로 판명)
1. **페이지 내 검색은 네이티브 API로 가능** — v1은 "JS 주입으로 직접 구현"이라 판단했으나 **WebView2 1.0.3405.78부터 `CoreWebView2.Find`가 네이티브 제공**된다 (우리 버전 1.0.4078.44에 포함). `StartAsync(options)` + `FindNext()/FindPrevious()` + `MatchCount`/`ActiveMatchIndex` 이벤트. `SuppressDefaultFindDialog=true`로 자체 UI 구축. → **공수 대폭 감소, 탭 이전에도 독립 구현 가능**
2. **백그라운드 탭 절전도 네이티브** — `CoreWebView2.TrySuspendAsync()`/`Resume()` (WebView가 숨겨진 상태여야 함). Edge Sleeping Tabs와 1:1 대응.
3. **탭 오디오 표시/음소거 네이티브** — `IsDocumentPlayingAudio`/`IsMuted` + 변경 이벤트.
4. **다중 프로필이 하나의 환경에서 가능** — `CreateCoreWebView2ControllerOptions(ProfileName)` → 브라우저 프로세스 추가 없이 Firefox 컨테이너 탭 유사 격리. `CustomDataPartitionId`로 탭 단위 쿠키 격리도 가능.
5. **Arc 브라우저는 2025-05 개발 중단** (유지보수 모드) — 디자인 참고용으로만 차용.
6. **Edge Collections/Drop/사이드바는 2026년 폐지 예정** — 클론하지 않음. 살아남는 Edge 자산은 세로 탭·절전 탭·분할 화면·웹 캡처·몰입형 리더.
7. **WPF airspace 주의** — 표준 `WebView2`(HwndHost) 위에는 WPF 요소를 올릴 수 없음. `WebView2CompositionControl`은 DRM 영상 재생 불가(영상 앱엔 치명적) → **표준 컨트롤 유지, 크롬 UI는 WebView2 주변에 배치**하는 설계 원칙 확정.
---
## 0. 현재 상태 (AS-IS)
`MainWindow.xaml(.cs)` 단일 `WebView2` 인스턴스 기반:
| 영역 | 구현 여부 | 비고 |
|---|---|---|
| 주소창 + 이동 + 자동완성/제안 | ✅ | 히스토리 기반, 인라인 자동완성(Tab/→) |
| 뒤로/앞으로/새로고침/홈 | ✅ | 상태 동기화 |
| 북마크 / 히스토리 | ✅ | 플라이아웃, JSON 영속(500 캡) |
| InPrivate 모드 | ✅ | 재시작 적용 |
| 단축키 | ✅(일부) | Ctrl+L, F5/Ctrl+R, Esc, Alt+←/→, Ctrl+±/0 |
| 줌 | ✅ | 세션 한시적(사이트별 영속 아님) |
| 팝업 → 현재 WebView 라우팅 | ✅ | `NewWindowRequested` |
| 페이지 다운로드 → 저장 폴더 | ✅ | `DownloadStarting` |
| 영구 프로필 / 데이터 삭제 | ✅ | `%AppData%/VideoDownloader/webview` |
| 다크/라이트 테마, 보더리스 | ✅ | DWM 연동 |
| 우측 다운로드 패널(접기/폭) | ✅ | |
| DevTools, 미디어 자동 감지 | ✅ | HLS/DASH/직링크/소셜(yt-dlp) |
**결여**: 탭 · 페이지 내 검색 · 다운로드 매니저 UI · 검색엔진 · 북마크바 · 명령 팔레트 · 리더 모드 · 스크린샷 · 전체화면 · 사이트별 설정
---
## 1. WebView2 API 역량 매트릭스 (2차 조사 검증 완료)
> 우리 버전(1.0.4078.44) 기준. 구현 계획의 기술적 토대.
| 필요 기능 | 네이티브 API | 비고 |
|---|---|---|
| **페이지 내 검색** | `CoreWebView2.Find` → `StartAsync(CoreWebView2FindOptions)` / `FindNext()` / `FindPrevious()` / `Stop()`, `MatchCountChanged`·`ActiveMatchIndexChanged` 이벤트 | 대소문자·단어일치·전체 강조 옵션 내장. `SuppressDefaultFindDialog=true` 필수(자체 UI) |
| **다중 탭** | 공유 `CoreWebView2Environment` + N개 `WebView2` 컨트롤 | 인스턴스당 메모리 부담 → 절전과 짝꿍. 브라우저 프로세스 1개 공유 |
| **탭 절전** | `TrySuspendAsync()` / `Resume()` / `IsSuspended` / `MemoryUsageTargetLevel` | 숨겨진(비활성) 탭에서만 동작. 자동으로 Low 모드 진입 |
| **오디오** | `IsMuted`(읽기/쓰기) · `IsDocumentPlayingAudio` · 변경 이벤트 | 음량 조절은 불가(뮤트만). 초기화 전 뮤트 설정은 안 먹는 버그(#2120) |
| **컨테이너/프로필** | `CreateCoreWebView2ControllerOptions(ProfileName, IsInPrivateModeEnabled)`, `CoreWebView2Profile`, `CustomDataPartitionId` | 한 환경에서 여러 프로필 = 프로세스 추가 없이 쿠키/세션 격리 |
| **다운로드 관리** | `DownloadStarting` + `CoreWebView2DownloadOperation`: `Pause()/Resume()/Cancel()`, `BytesReceived`/`TotalBytesToReceive`/`State`/`InterruptReason`, 이벤트 3종 | 속도는 BytesReceived 델타로 자체 계산 |
| **팝업→새 탭** | `NewWindowRequested`: `e.NewWindow = 새탭.CoreWebView2`, `GetDeferral()` | 탭 생성이 비동기라 deferral 필수 |
| **줌** | `Controller.ZoomFactor` + `ZoomFactorChanged` | 컨트롤러(탭) 단위. **사이트별 영속은 WebView2이 보장 안 함** → 앱 설정에 도메인→배율 저장 후 `NavigationCompleted` 때 재적용 |
| **파비콘/제목** | `DocumentTitle(Changed)`, `FaviconUri(Changed)`, `GetFaviconAsync(Png)` | 탭 UI 재료 |
| **컨텍스트 메뉴** | `ContextMenuRequested` + `CreateContextMenuItem` | 기본 메뉴 항목 재구성 + 커스텀 항목(「이 링크의 영상 받기」 등) |
| **권한** | `PermissionRequested` + `Profile.SetPermissionStateAsync(kind, origin, state)` | 알림/카메라/마이크/자동재생 등 사이트별 영속 |
| **쿠키** | `CookieManager`(Profile 스코프) | |
| **인쇄/PDF** | `ShowPrintUI()`, `PrintAsync(settings)`, `PrintToPdfAsync(path)` | |
| **C#↔JS 브리지** | `PostWebMessageAsJson` ↔ `WebMessageReceived`, `AddScriptToExecuteOnDocumentCreatedAsync` | 리더 모드/부스트/요소 zap의 기반. CSP에 막히지 않음(단 Trusted Types DOM 싱크 주의) |
| **알림** | `NotificationReceived` (비영속 알림만, 호스트가 직접 표시) | 푸시(서비스워커) 알림은 미지원 |
| **스크린샷** | `CapturePreviewAsync`(뷰포트만) / CDP `Page.captureScreenshot`(`captureBeyondViewport:true`로 전체 페이지) | 전체 페이지 캡처는 CDP 경유 |
| **오류 페이지** | `IsBuiltInErrorPageEnabled=false` + 실패 시 `NavigateToString` | 재진입 가드 필요(#634) |
| **상태바 링크 미리보기** | `StatusBarText(Changed)` | 링크 호버 시 하단 표시 |
**자체 구현 필요** (네이티브 없음): 리더 모드(Readability.js 주입) · 검색엔진 로직(순수 호스트) · 히스토리/북마크 저장(이미 보유) · 명령 팔레트 · 탭 그룹/워크스페이스(UI 개념)
---
## 2. 조사 결과 종합 — 9개 브라우저의 차별 기능
### Chrome (표준의 척도)
탭 그룹(색+이름+접기, **저장된 그룹**), Memory Saver(유휴 탭 동결), **탭 검색 Ctrl+Shift+A**, `@tabs`/`@bookmarks`/`@history` 주소창 스코프 검색, 키워드 사이트 검색(`wiki`+Tab), Ctrl+Shift+T 복원, 다운로드 버블(진행 링), 사이트별 줌 영속, 빠른 닫힘. 2025+ : 세로 탭 추가, AI 탭 정리.
### Edge (WebView2의 친척 — 가장 직접 참고)
**세로 탭**(좌측 사이드바, 아이콘 레일 축소+호버 확장), **Sleeping Tabs**(30초~12h 설정, "zzz" 표시, 절약량 표시), **네이티브 분할 화면**, **웹 캡처**(영역/전체 페이지+주석, Ctrl+Shift+S), **몰입형 리더**(읽어주기 Ctrl+Shift+U, 문법 도구), 즐겨찾기바 "아이콘만" 모드, F4=주소창. (Collections/Drop/사이드바는 2026 폐지 — 미차용)
### Firefox
**Reader View(F9)** — 폰트/간격/테마(밝/어/세피아/대비) + **낭독(TTS)**; **Multi-Account Containers**(쿠키 격리 다중 로그인); **Enhanced Tracking Protection**(실드 아이콘, 3단계); HTTPS-Only; **빠른 찾기** `/`(즉시), `'`(링크만); **PiP**(자동 열림 옵션); 다운로드 패널(.part 재개).
### Vivaldi (커스터마이징의 왕)
**Quick Commands(F2)** — 전 브라우저 액션 명령 팔레트; **Tab Stacks**(드래그 스택, Compact/2단/아코디언 표시); **Tab Tiling**(수직/수평/그리드, Ctrl+F9/F8/F7); **Workspaces + 규칙**(URL 패턴→워크스페이스 자동 라우팅!); **북마크 닉네임**(`g 검색어`); **Speed Dial**(새 탭 썸네일 그리드); **Web Panels**(사이드바 고정 사이트); **Notes**(마크다운, "Add to Note"); **Page Actions**(CSS 필터: 흑백/반전/세피아/가리개); **Capture**(전체/영역); 주소창 진행 바, 파라미터 제거 복사, 마우스 제스처, 툴바 편집.
### Zen Browser (Firefox 포크, 활발)
**세로 탭 철학** + **Essentials**(전 워크스페이스 상시 아이콘 고정); **Workspaces**(탭 세트 분리); **Split View**(최대 4분할); **Glance**(Alt+클릭 링크 미리보기 플로팅, 닫기/탭으로/분할 버튼); **Compact Mode**(사이드바 숨김+엣지 호버 복원); **Boosts**(사이트별: 색 틴트/폰트/요소 zap/강제 다크); 핀 탭 닫으면 언로드(삭제 아님); Mods(CSS+JS 마켓).
### Arc (개발 중단 — 개념만 차용)
**Command Bar(Ctrl+T 스포트라이트)** — 탭/북마크/히스토리/명령/웹검색 통합; **Spaces**(테마별 공간); **Auto Archive**(미고정 탭 12h 후 아카이브, 닫기=아카이브, 검색 가능한 복구 목록); **Boosts**(슬라이더: 색/대비/밝기/채도/세피아/다크/줌 + CSS); **Peek**(Shift+클릭 미리보기); **Air Traffic Control**(도메인→Space 자동 라우팅); URL 바 **탭별 입력 초안 기억**.
### Brave
**Playlist** ★영상 앱에 최적 — 주소창 "미디어 추가" 버튼으로 페이지 영상/오디오를 재생목록에 저장, 백그라운드 재생, 광고 없는 재생, 오프라인 저장; Shields(사이트별 차단 슬라이더 + 차단 카운터); Speedreader(자동 리더).
### Opera
**Tab Islands**(같은 부모에서 연 탭 자동 그룹); **Video Popout**(호버 버튼→분리 플레이어); 사이드바 메신저; 무료 VPN; Speed Dial; Workspaces.
### Safari / 기타
Safari: Tab Groups + Reading List(오프라인 나중읽기), 컴팩트 탭바. Floorp: 멀티로우 탭. Orion: Lazy Load(포커스 시 로드). (Sidekick 2025 종료, Comet/Dia = AI 브라우저 흐름 참고)
---
## 3. 도입 기능 목록 (v2 — 재서열)
### 🏆 Tier 1 — 필수 (일반 브라우저의 기본기)
| # | 기능 | 근원 | 구현 경로 | 공수 |
|---|---|---|---|---|
| T1-1 | **탭 시스템** (생성/닫기/전환/Ctrl+Tab·1~9/드래그 정렬/복제) | 전 브라우저 | 공유 Environment + 탭당 WebView2 + `NewWindowRequested`→새 탭(deferral) | L |
| T1-2 | **닫은 탭 복원** Ctrl+Shift+T | 전 브라우저 | 닫힘 스택(인덱스+URL 보존) | S |
| T1-3 | **페이지 내 검색** Ctrl+F (일치 수 X/Y, 다음/이전, 대소문자) | 전 브라우저 | **네이티브 `CoreWebView2.Find`** + 자체 하단 바 | **S** ↓↓ |
| T1-4 | **검색엔진** (기본 엔진 선택 + 키워드 전환 `g `, `yt ` + `@북마크/@기록` 로컬 스코프) | Chrome/Vivaldi/Firefox | 순수 호스트 로직 + 제안 드롭다운 확장 | S~M |
| T1-5 | **다운로드 매니저 UI** (진행+속도, 일시정지/재개/취소, 폴더 열기, Ctrl+J) | Chrome 버블/Vivaldi 패널 | `CoreWebView2DownloadOperation` 풀 API + 앱 카드와 통합 뷰 | M |
| T1-6 | **북마크바** (주소창 아래, Ctrl+Shift+B, 아이콘만 모드) | Chrome/Edge | UI 행 | S |
| T1-7 | **사이트별 줌 영속** | 전 브라우저 | 도메인→배율 사전 저장 + `NavigationCompleted` 재적용 | S |
| T1-8 | **탭 오디오 표시 + 클릭 음소거** | Chrome/Edge | `IsDocumentPlayingAudio`/`IsMuted` 네이티브 | S |
| T1-9 | **강제 새로고침** Ctrl+Shift+R | 전 브라우저 | `Profile.ClearBrowsingDataAsync(DiskCache)` 후 Reload | S |
| T1-10 | **세션 복원** (시작 시 이전 탭 복구, 옵션) | Chrome/Firefox | 탭 URL 리스트 영속 | S |
| T1-11 | **전체화면 F11** + 비디오 전체화면 지원 | 전 브라우저 | `ContainsFullScreenElement` 이벤트 + 크롬 자동 숨김 | S |
### 🥈 Tier 2 — 강력 추천 (차별화 + 사용성 대폭 향상)
| # | 기능 | 근원 | 구현 경로 | 공수 |
|---|---|---|---|---|
| T2-1 | **명령 팔레트** (Ctrl+K: 탭/북마크/기록/액션/웹검색 통합) | Arc Command Bar / Vivaldi Quick Commands | 호스트 UI + 기존 데이터 재사용 | M |
| T2-2 | **탭 절전** (유휴 탭 자동 서스펜드, "zzz" 표시, 예외 사이트) | Edge Sleeping Tabs / Zen | `TrySuspendAsync`/`Resume` + 유휴 타이머 | M |
| T2-3 | **분할 화면** (2~4분할, 드래그 배치, 분할 닫기) | Zen Split / Edge / Vivaldi Tiling | Grid + GridSplitter + WebView2 복수 | M~L |
| T2-4 | **리더 모드** (F9, 본문 추출 + 폰트/테마) | Firefox/Zen | Readability.js 주입 + 스타일 오버레이 | M |
| T2-5 | **세로 탭 레이아웃** (좌측 사이드바, 축소 레일, 토글) | Edge/Zen | 탭 스트립 위치 전환 | M |
| T2-6 | **Glance 링크 미리보기** (Alt+클릭 → 플로팅 미리보기, 탭으로 승격 버튼) | Zen Glance / Arc Peek | 오버레이 WebView2 창 | M |
| T2-7 | **사이트별 Boost** (강제 다크 / 색 틴트 / 요소 zap / 줌 기억) | Zen·Arc Boosts | 도메인별 CSS 주입 `AddScriptToExecuteOnDocumentCreated` | M |
| T2-8 | **탭 검색** Ctrl+Shift+A (열린+최근 닫힘 탭 검색 점프) | Chrome/Edge | 호스트 UI | S |
| T2-9 | **스크린샷** (보이는 영역 / 전체 페이지, Ctrl+Shift+S) | Firefox/Edge/Vivaldi | `CapturePreviewAsync` / CDP `captureBeyondViewport` | S~M |
| T2-10 | **미디어 재생목록** ★영상 앱 핵심 — 감지된 영상을 재생목록에 쌓고 앱 내 재생/백그라운드 | Brave Playlist | 감지 파이프라인 + 내장 플레이어 컨트롤 | L |
| T2-11 | **탭 그룹** (색+이름, 접기, 도메인별 자동 그룹) | Chrome 탭 그룹 / Opera Islands | 탭 모델 확장 | M |
| T2-12 | **웹 캡처 → 다운로드 큐 연동** (우클릭 "이 링크 영상 받기") | 커스텀 | `ContextMenuRequested` 커스텀 항목 | S |
### 🥉 Tier 3 — 선택 (돋보이는 기능)
| # | 기능 | 근원 | 비고 |
|---|---|---|---|
| T3-1 | **워크스페이스** + **도메인 라우팅 규칙** ("youtube.com → 영상 공간") | Zen/Vivaldi 규칙/Arc ATC | 탭 세트 스왑 |
| T3-2 | **Auto-Archive 탭 수명주기** (닫기=아카이브, 캐던스 설정, 복구 패널) | Arc | 히스토리와 통합 |
| T3-3 | **컨테이너/다중 프로필** (탭별 쿠키 격리 다중 로그인) | Firefox/Zen | `ProfileName`/`CustomDataPartitionId` |
| T3-4 | **빠른 찾기** `/` · `'`(링크만) | Firefox | Find API 래핑 |
| T3-5 | **컨텍스트 메뉴 전면 커스터마이징** | — | `ContextMenuRequested` (T2-12 확장) |
| T3-6 | **Page Actions** (CSS 필터: 흑백/반전=다크/세피아/가리개) | Vivaldi | Boost와 통합 가능 |
| T3-7 | **PiP 영상 팝아웃** (호버 버튼 → 항상 위 창) | Firefox/Opera | Document PiP API or 별도 창 |
| T3-8 | **Speed Dial 새 탭 페이지** (썸네일 그리드) | Opera/Vivaldi | 내장 HTML |
| T3-9 | **읽어주기(TTS)** | Edge/Firefox | 리더 모드와 세트, Windows TTS |
| T3-10 | **인쇄/PDF** Ctrl+P / 소스보기 Ctrl+U | 전 브라우저 | `PrintToPdfAsync` 등 네이티브 |
| T3-11 | **권한 UI** (알림/카메라/마이크/자동재생 사이트별) | Chrome/Firefox | `PermissionRequested` |
| T3-12 | **노트 패널** (마크다운, "Add to Note") | Vivaldi/Arc | 후순위 |
| T3-13 | **컴팩트 모드** (크롬 전체 숨김, 엣지 호버 복원) | Zen/Arc | 전체화면과 통합 |
| T3-14 | **추적 방지 표시** (차단 카운터 실드) | Firefox/Brave | `WebResourceRequested` 필터 |
| T3-15 | **주소창 진행 바 + 파라미터 제거 복사** | Vivaldi | 소소한 완성도 |
**❌ 의도적 미차용**: 비밀번호 관리(자동저장 토글만), 실제 확장 프로그램 생태계, 클라우드 동기화, AI 사이드바(방향 불명확), Edge Collections/Drop(폐지), VPN/Tor(범위 외)
---
## 4. 구현 로드맵 (의존성 순, v2)
### Phase 0 — 즉효 퀵윈 (탭 이전 독립 구현 가능, 반나절~1일)
> 신규 발견: 네이티브 API 덕에 탭 대공사 없이 먼저 넣을 수 있는 것들.
- [ ] **T1-3 페이지 내 검색** — `CoreWebView2.Find` + 하단 검색 바(Ctrl+F/F3/Esc). *가성비 1위로 승격*
- [ ] **T1-4 검색엔진** — URL 판별 → 검색 URL 조합, 기본 엔진 설정(Google/Bing/DDG), `g ` 키워드
- [ ] **T1-7 사이트별 줌 영속**
- [ ] **T1-9 강제 새로고침**
- [ ] **T1-11 전체화면 F11**
### Phase A — 탭 아키텍처 (대공사, 모든 탭 기능의 전제)
- [ ] `Browser/TabViewModel` + `TabManager` 신규 — 공유 `CoreWebView2Environment`, 탭당 WebView2
- [ ] 탭 스트립 UI(가로 우선, 세로는 T2-5) + 활성 탭만 Visible
- [ ] 기존 로직 전면 리팩터: 주소창/네비/감지(`OnResponseReceived` 전 탭 구독→중계)/북마크/히스토리/DetectSocial → 활성 탭 대상
- [ ] 단축키: Ctrl+T/W/Tab/1~9, Ctrl+Shift+T(T1-2 복원 스택)
- [ ] `NewWindowRequested` → 새 탭(deferral 패턴) 교체
- [ ] T1-8 오디오 표시/뮤트, T1-10 세션 복원
- ⚠️ 저장소가 git이 아님 → **착수 전 `git init` 필수**. 감지 로직 회귀 테스트 체크리스트 작성.
### Phase B — 탭 보조 + 핵심 완성
- [ ] **T1-5 다운로드 매니저** — 페이지 다운로드(`DownloadOperation`) + 영상 카드 통합 뷰, 속도/일시정지/재개
- [ ] **T1-6 북마크바** (+아이콘만 모드)
- [ ] **T2-2 탭 절전** — 유휴 타이머 → `TrySuspendAsync`, 재활성 `Resume`
- [ ] **T2-8 탭 검색** Ctrl+Shift+A
- [ ] **T2-12 우클릭 "이 링크 영상 받기"** — 컨텍스트 메뉴 커스텀 (다운로더 앱의 시그니처)
### Phase C — 차별화 (Zen/Arc/Vivaldi 영감)
- [ ] **T2-1 명령 팔레트** Ctrl+K — 탭/북마크/기록/액션(다운로드·줌·테마·InPrivate)/웹검색
- [ ] **T2-3 분할 화면** — 플레이어 + 다운로드 큐 용도
- [ ] **T2-4 리더 모드** — Readability.js 번들
- [ ] **T2-6 Glance** Alt+클릭 미리보기
- [ ] **T2-7 Boost** — 강제 다크/틴트/요소 zap(사이트별 광고 제거)
- [ ] **T2-9 스크린샷**, **T2-5 세로 탭**, **T2-11 탭 그룹**
### Phase D — 시그니처 + 정제
- [ ] **T2-10 미디어 재생목록** ★ — 감지된 영상 → 재생목록 패널 → 내장 재생/백그라운드 (Brave Playlist 관점, 우리 앱의 정체성)
- [ ] T3-1 워크스페이스+도메인 라우팅, T3-2 Auto-Archive, T3-3 컨테이너, T3-7 PiP, T3-8 Speed Dial 등
---
## 5. 우선순위 TOP 5 (v2 — 공수 재평가 반영)
| 순위 | 기능 | 이유 |
|---|---|---|
| 1 | **Phase 0 전체** (검색·검색엔진·줌·F11) | 탭 없이도 즉시 체감. 특히 페이지 내 검색은 네이티브 API로 반나절. |
| 2 | **Phase A 탭** | 브라우저 정체성의 기반. 이후 모든 것의 전제. |
| 3 | **다운로드 매니저 (T1-5)** | 다운로더 앱인데 관리 UI 부재가 최대 모순. |
| 4 | **명령 팔레트 (T2-1)** | 모든 기능의 통합 입구 — 기능이 많아질수록 가치 증가. |
| 5 | **미디어 재생목록 (T2-10)** | Brave Playlist 관점 — "영상 다운로더 브라우저"의 시그니처 기능. |
---
## 6. 기술 노트 & 리스크 (2차 검증 반영)
- **airspace**: 표준 `WebView2`(HwndHost)는 최상위 렌더 → WPF 오버레이 불가. `WebView2CompositionControl`은 DRM 영상 실패 → **불가침 원칙: 크롬 UI는 WebView2를 덮지 않고 둘러싼다**. (Glance/커맨드 팔레트는 별도 창 or 여백 배치)
- **탭 메모리**: 인스턴스당 수십 MB. → 절전(T2-2)을 Phase B에 배치, 최대 탭 수 가드(옵션), Zen식 "핀 탭 닫으면 언로드" 채택 고려.
- **줌 영속**: WebView2이 사이트별 줌을 보장하지 않음(#2451/#3459) → 앱 설정 사전이 SSOT.
- **Find API**: 옵션 변경 시 재 Start. HTML/텍스트 문서만. `SuppressDefaultFindDialog=true`로 Edge 기본 바 차단.
- **뮤트 버그**: 첫 네비게이션 전 `IsMuted=true`는 무시될 수 있음(#2120) → 초기화 후 설정.
- **ClearBrowsingDataAsync 셧다운**: 앱 종료 중 호출 미완료 이슈(#3176) → 종료 경로에서 호출 금지.
- **Trusted Types**: 일부 사이트에서 `innerHTML` 주입이 막힘 → `textContent`/`createElement` 사용.
- **미디어 감지 다중화**: `WebResourceResponseReceived`를 탭마다 구독하되 `TabManager`가 출처 태깅 후 `DownloadManager.OnDetected` 중계 (카드에 탭 출처 표시).
---
## 7. 성공 기준 (완료 정의)
- [ ] Ctrl+F 검색 바: 일치 수 표시, 다음/이전, Esc 닫기 동작
- [ ] 주소창에 검색어 → 선택한 엔진으로 검색; `g `/`yt ` 키워드 전환 동작
- [ ] 여러 탭 열기/전환/닫기, 각 탭 독립 네비게이션·감지, Ctrl+Shift+T 복원
- [ ] 탭에서 오디오 재생 시 스피커 아이콘 + 클릭 뮤트
- [ ] 사이트별 줌 재방문 시 유지
- [ ] 다운로드 매니저에서 페이지 다운로드 일시정지/재개/취소 + 속도 표시
- [ ] 북마크바 표시/숨김(Ctrl+Shift+B) + 원클릭 이동
- [ ] 종료 시 탭 저장 → 시작 시 복원(옵션)
- [ ] `dotnet build` 0 오류/0 경고, 기존 미디어 감지·다운로드 회귀 없음
---
## 출처 (2차 조사 핵심 링크)
**WebView2 API (Microsoft Learn)**:
- CoreWebView2.Find / CoreWebView2Find: learn.microsoft.com/en-us/dotnet/api/microsoft.web.webview2.core.corewebview2.find
- 성능(서스펜션): learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/performance
- 멀티 프로필: learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/multi-profile-support
- 다운로드: …/corewebview2downloadoperation · 컨텍스트 메뉴: …/webview2/how-to/context-menus
- WPF 플랫폼(airspace/CompositionControl): …/webview2/platforms/wpf
**Chrome/Edge**: support.google.com/chrome/answer/157179 (단축키) · …/2391819 (탭 관리) · explore.microsoft.com/en-us/edge/features (vertical tabs·sleeping tabs·split screen·web capture) · support.microsoft.com/…/keyboard-shortcuts-in-microsoft-edge
**Firefox/Vivaldi**: support.mozilla.org (keyboard shortcuts·reader view·ETP·HTTPS-only·PiP·screenshots) · help.vivaldi.com (tab-stacks·tab-tiling·workspaces·address-field·quick-commands·web-panels·page-actions)
**Zen/Arc**: docs.zen-browser.app (workspaces·split-view·glance·compact-mode·urlbar·shortcuts) · resources.arc.net (Spaces·Auto-Archive·Boosts·Peek·Air Traffic Control·Command Bar)
**Brave/Opera/기타**: brave.com/playlist · brave.com/shields · opera.com/features (tab-islands·video-popout·workspaces) · docs.floorp.app · Apple Support (Safari tab groups·reading list)
**전체 원문 리포트**: 5개 조사팀 상세 보고서는 세션 에이전트 결과로 보존됨.

246
MOBILE_PLAN.md Normal file
View file

@ -0,0 +1,246 @@
# Video Downloader — 모바일 연동 + 모바일 앱 계획서 (v1)
> **목표**: Windows 비디오 다운로더(내장 브라우저)에 **모바일 앱을 만들고 PC↔모바일을 연동**한다. 핵심 질문 — "모바일에서 이 앱과 무엇을 하게 할 것인가?"에 대한 조사 기반 답변.
>
> **작성일**: 2026-08-15 · **관련 문서**: `BROWSER_FEATURES_PLAN.md` (v2, 데스크톱 브라우저 기능)
>
> **조사 방법론**: 4개 전문 조사팀 병렬 심층 조사 — ① 모바일 브라우저 UX(Chrome/Safari/Firefox/Samsung/Brave 모바일) ② PC↔모바일 연동 패턴(20여 개 제품: Chrome Send Tab, Handoff, Phone Link, LocalSend, KDE Connect, Jellyfin 등) ③ 모바일 영상 생태계(iOS/Android 제약, 다운로더 앱 생존 모델, 미디어 서버) ④ 기술 스택 검증(**실제 코드베이스 grep 포함**). 총 73회 웹 검색 + 공식 문서 열람.
---
## 0. 핵심 결론 (Executive Summary)
1. **제품 형태 = "컴패니언 앱" 우선**. PC가 일꾼(추출·다운로드), 폰은 리모컨+뷰어. 이는 Jellyfin/VLC 리모컨/Chromecast 모델로 검증된 구조이며, 양쪽 앱스토어 정책 리스크도 최소화함.
- 폰에서: ① 링크 보내 PC가 받게 하기 ② 다운로드 큐 원격 제어(진행률 실시간) ③ PC 라이브러리 스트리밍 시청 ④ 완료된 영상 폰으로 저장(오프라인)
2. **아키텍처 = 클라우드 없는 LAN 직결**. 데스크톱 앱에 Kestrel 서버를 내장(REST + SignalR), QR 코드로 페어링(주소+토큰 동시 전달). LocalSend/KDE Connect가 증명한 계정 없는 지속 가능한 모델. 원격 접근은 자체 릴레이 대신 Tailscale/VPN 문서화.
3. **기술 스택 = .NET MAUI 10 (net10.0-android/ios)**. 기존 `VideoDownloader.Core`(net8.0) 엔진을 **~90% 그대로 재사용** — 모바일 비호환 지점은 단 4곳(§4 검증 표). Flutter/네이티브는 Core 재사용이라는 이 프로젝트의 존재 이유를 포기해야 하므로 기각. Avalonia는 데스크톱 실험용 유지.
4. **플랫폼 전략 = Android 먼저(APK/GitHub 배포), iOS는 컴패니언 프레이밍**.
- **Android**: 자유도 최대 — 나중에 독립 다운로더 모드 추가 가능(YoutubeExplode 순수 C#).
- **iOS**: WebKit 강제 사실상 지속 + **Apple이 Brave의 오프라인 영상 저장을 금지한 전례** → 기기 내 추출 코드 배제, "내 PC 라이브러리 원격 시청" 프레이밍만.
5. **제로 설치 웹 컴패니언 겸비**: 데스크톱 앱이 폰 브라우저용 페이지를 직접 서빙 → 앱 설치 없이 QR 찍으면 즉시 큐 모니터링 가능(1차 검증 수단).
---
## 1. 조사 결과 종합
### 1.1 모바일 브라우저 UX 관례 (앱 UI의 기준선)
| 관례 | 내용 | 출처 |
|---|---|---|
| **하단 주소창 기본** | Chrome 2025 정식 탑재(롱프레스로 전환), Safari/iOS 26도 상하단 선택. 단 Chrome의 실수(탭 버튼 숨김)는 반면교사 — 하단 바에 탭 버튼 유지 | Chrome/Safari |
| **전체 화면 탭 그리드** | 카드 썸네일 + 스와이프 닫기 + 탭 검색, 탭 수 배지 | 전 브라우저 |
| **제스처** | 화면 엣지 스와이프=뒤로/앞으로, 주소창 가로 스와이프=탭 전환, 당겨서 새로고침(비활성화 가능), 툴바 아래로 스와이프=몰입 모드 | Chrome/Safari/Samsung |
| **다운로드 관리자** | ⋮ 메뉴 속 풀페이지 화면(데스크톱 셸프와 다름). **우리 앱은 다운로드가 본업이므로 하단 바 전용 슬롯 + 감지 배지로 승격** | Chrome Android |
| **영상 감지 UI** | **Samsung Video Assistant**(영상 위 오버레이 → 팝업 플레이어), **Brave Playlist**(탭 아이콘 → 재생목록 추가) — 데스크톱 감지 배너의 모바일 등가물 | Samsung/Brave |
| **리더/다크** | URL 옆 아이콘(Safari 배치), 시스템 테마 추종 + 강제 다크 토글(Firefox Night Mode) | Safari/Firefox |
### 1.2 PC↔모바일 연동 — 제품별 매트릭스 (20개 제품 조사)
| 제품 | 전송물 | 페어링 | 구조 |
|---|---|---|---|
| Chrome Send Tab | 탭/링크 | **계정**(클라우드) | 클라우드 동기화+푸시 |
| Safari Handoff | 앱 상태 통째로 | iCloud+BLE 근접 | P2P Wi-Fi(AWDL) |
| Phone Link | 알림/SMS/사진 | BT+MS 계정 | 하이브리드 |
| Firefox Send Tab | 탭 | **QR 페어링**(FxA) | **E2E 암호화 릴레이** |
| **LocalSend** | 파일/텍스트 | 같은 Wi-Fi+PIN | **순수 LAN, 계정 없음** |
| **KDE Connect** | 파일/클립보드/알림/입력 | LAN+TLS 인증서 확인 | **순수 LAN** |
| **Jellyfin** | 라이브러리/제어 | 서버 URL+API 키 | **자체 호스팅 REST+WebSocket** |
| VLC 리모컨 | 재생 제어 | 없음(HTTP 인터페이스) | 순수 LAN |
| Chromecast | 미디어 URL 위임 | mDNS | **폰=리모컨만, 미디어는 직접** |
| Intel Unison | 파일/SMS/알림 | 계정 | **2025-06 서비스 종료** ⚠️ |
**도출된 원칙**:
- 계정 기반 클라우드 동기화(Chrome/Edge)는 인프라+유지 비용 — 인디 규모에서 Unison처럼 죽는다. **계정 없는 LAN 모델이 지속 가능한 니치**.
- **QR 페어링 = 발견+키 교환을 동시에** 해결하는 최고의 UX 트릭(WhatsApp Web/Firefox). `http://<ip>:<port>/#t=<토큰>`.
- LocalSend 보안 모델 차용: 자가서명 인증서 지문 양쪽 화면 표시(목측 검증), 선택적 PIN, 전송 전 수락 프롬프트("폰이 다운로드 3건 요청 — 수락?").
- **크로미캐스트 교훈**: 폰은 URL/제어 패킷만 보내고 미디어 바이트를 프록시하지 않는다. 폰 앱은 큐 대상 **stateless** 설계(앱을 꺼도 PC 다운로드는 계속).
- 미러링하지 말 것: 알림 미러링(특권+유지비), BLE 근접(데스크톱 전파 신뢰성 낮음), 상시 클립보드 동기화(프라이버시 표면).
### 1.3 모바일 영상 생태계 — 플랫폼 제약표
| 항목 | iOS | Android |
|---|---|---|
| 다운로더 정책 | 5.2.2 위반 시 리젝. **Brave Playlist 오프라인 저장 금지 전례**. 생존 모델 = "파일 관리자+브라우저"(Documents by Readdle) 프레이밍 | Play도 실질 금지(TubeMate 퇴출). **탈출구 = APK/F-Droid/GitHub 배포**(NewPipe·Seal·YTDLnis 전부) |
| 백그라운드 다운로드 | `NSURLSession` 백그라운드(앱 종료해도 계속, 강제종료 시 예외) | FGS `dataSync`(~6h 제한·폐지 예정) → **WorkManager가 정답**(YTDLnis 방식) |
| 백그라운드 재생/PiP | Background Modes + AVAudioSession `.playback` | `mediaPlayback` FGS + 상시 알림 |
| 파일 저장 | 샌드박스만. 사진 보관함/Files 내보내기 | MediaStore.Downloads(자기 파일은 권한 불필요) |
| 스트리밍 프로토콜 | **AVPlayer = HLS만, DASH 불가** | ExoPlayer/Media3 = HLS+DASH 네이티브 |
| yt-dlp 실행 | **불가능**(서브프로세스 금지) | 가능(Termux제 Python 번들) — 단 GPL, Play 배포 불가 |
- **미디어 서버 참고**: Jellyfin(REST+WebSocket, 다운로드/포지션 동기화), **Streamyfin**(클라이언트 측 HLS→로컬 파일 변환으로 "다운로드" 구현 — 재생 가능=다운로드 가능 구조), Plex(코덱 호환 시 원본 직접, 아니면 트랜스코드).
- **오프라인 라이브러리 UX 기대치**(Netflix/Spotify): 전용 다운로드 탭, 화질/크기 선택기+저장공간 대시보드, 백그라운드 큐, 재시작 생존.
- **수요 배경**: YouTube가 2026년 비프리미엄 백그라운드 재생 루트를 봉쇄 중 → "내 라이브러리 무제한 백그라운드/PiP"는 진짜 차별점.
### 1.4 기술 스택 검증 (실제 코드 grep 기반)
**스택 비교 평결**:
| 스택 | Core 재사용 | 모바일 생태계 | 평결 |
|---|---|---|---|
| **.NET MAUI 10** | ~100% (컴패니언+독립 모두) | SignalR 공식 지원, ZXing.Net.Maui, MediaElement | ✅ **채택** |
| Avalonia 12 모바일 | 동일 | 얇음(QR 스캐너/플레이어 없음, 네이티브 직접 붙여야) | 데스크톱 실험 유지 |
| Flutter | 0% (Dart 재작성) | 최고 | 기각 — Core 재사용 포기 |
| Kotlin/Swift 네이티브 | 0% | 최고 | 기각 — 코드 2벌 |
**버전 주의**: MAUI 8/9는 이미 지원 종료 → 신규 MAUI 앱은 **.NET 10 대상 필수**. 다행히 `net10.0-android`는 기존 `net8.0` Core 어셈블리를 그대로 참조 가능.
**`VideoDownloader.Core` 모바일 공유 분석 (파일:라인 검증 완료)**:
| 상태 | 위치 | 내용 |
|---|---|---|
| ✅ 그대로 사용 | `Hls/HlsDownloader`(병합 호출 제외), `M3u8Parser`, `Dash/DashParser·Models`, `Models/*`, `Social/CookieRecord·FormatOption`, `Util/FileNameUtil` | HttpClient 기반 — 모바일 완전 호환 |
| ⚠️ 추상화 필요 | `Social/YtDlpRunner.cs:131-181`, `DenoRunner.cs` | `Process.Start` → `IProcessRunner`/`IToolLocator` 인터페이스로 추출 |
| ⚠️ 추상화 필요 | `Hls/HlsDownloader.cs:237`, `Dash/DashDownloader.cs:71` | ffmpeg 서브프로세스 → `IMediaMuxer` |
| ⚠️ 추상화 필요 | `Config/AppConfig.cs` | `SpecialFolder.MyVideos` → 플랫폼 경로 주입 |
→ **추상화 4곳만 하면 Core ~90% 공유.** 이 리팩터는 BACKLOG의 Avalonia 이관 로드맵 2단계(공유 리팩터)와 **동일한 작업** — 한 번의 리팩터로 모바일+크로스플랫폼 모두 준비됨 (시너지).
**Android 독립 모드용 YouTube**: Python 번들 대신 **YoutubeExplode**(순수 C#, Tyrrrz의 YoutubeDownloader 15.8k★가 사용) — MAUI 안에서 동작.
---
## 2. 유즈케이스 정의 (컴패니언 앱)
| # | 유즈케이스 | 흐름 | 근원 패턴 |
|---|---|---|---|
| U1 | **링크 → PC로 받기** | 폰 공유 시트(YouTube/웹페이지) → "PC에서 받기" → PC 대기열 즉시 추가 + 수락 프롬프트 | Chrome Send Tab + LocalSend 수락 프롬프트 |
| U2 | **큐 원격 제어** | 큐 목록 실시간(진행률/속도), 일시정지/재개/취소/재시도 | Jellyfin 리모컨 |
| U3 | **라이브러리 스트리밍** | PC 저장 폴더 그리드(썸네일+메타) → 탭하여 시청(Range HTTP 스트리밍, 이어보기 위치 양방향 동기화) | Jellyfin/Streamyfin |
| U4 | **폰으로 저장(오프라인)** | 라이브러리 항목 다운로드 → 앱 오프라인 라이브러리(Neflix식 다운로드 탭) | Plex Downloads |
| U5 | **완료 알림** | PC에서 다운로드 완료 → 폰 푸시(연결 중일 때) | — |
| U6 | **웹 컴패니언(제로 설치)** | 폰 카메라로 QR → 폰 브라우저에서 바로 큐/라이브러리 열람 | PairDrop/Jellyfin 웹 |
| U7 | (후기) **Android 독립 다운로더** | PC 없이 폰에서 직접 HLS/DASH/YouTube 받기 | YTDLnis/YoutubeExplode |
---
## 3. 아키텍처 설계
```
┌────────────────── Windows 앱 (WPF) ──────────────────┐
│ WebView2 브라우저 + 감지 DownloadManager (Core) │
│ ▲ │
│ ┌───────────────┴────────────────┐ │
│ VideoDownloader.Server (신규 클래스 라이브러리) │
│ · Kestrel (동적 포트, ListenAnyIP) │
│ · Bearer 페어링 토큰 (256bit, QR로 배포) │
│ · mDNS 광고 (_videodl._tcp, Makaretu.Dns) │
│ · 정적 웹 컴패니언 페이지 서빙 (제로 설치 클라이언트) │
└──────┬───────────────┬──────────────┬────────────────┘
│ LAN HTTP(S) │ SignalR │ Range HTTP
┌──────▼─────┐ ┌──────▼─────┐ ┌─────▼──────┐
│ MAUI 앱 │ │ 폰 브라우저 │ │ 미디어 │
│ (Android→ │ │ (웹 컴패니언)│ │ 재생 │
│ iOS) │ └────────────┘ └────────────┘
└────────────┘
```
**API 설계안** (LocalSend 프로토콜 + Jellyfin 참고):
- `POST /api/pair` — 토큰 검증 + 기기명 등록
- `POST /api/queue` — URL 추가(U1; 데스크톱 수락 프롬프트 연동)
- `GET /api/queue` · `POST /api/queue/{id}/pause|resume|cancel|retry` — 큐 제어(U2)
- `GET /api/library` (+`/{id}/thumb`) — 메타데이터 카탈로그(U3; ffprobe/FFMpegCore 썸네일, 사이드카 캐시)
- `GET /stream/{id}` — MP4 Range 요청(206) 스트리밍(U3; `Results.File(enableRangeProcessing:true)`)
- `GET /file/{id}` — 폰 저장용 다운로드(U4)
- `/hubs/queue` — SignalR 진행률/로그 푸시(U2/U5; v1은 2초 폴링으로도 가능)
- `POST /api/position/{id}` — 이어보기 위치 동기화(U3)
**보안 모델**: 페어링 토큰(Bearer, 상수시간 비교) + 재페어링 시 재생성. LAN HTTP는 iOS ATS `NSAllowsLocalNetworking` 예외로 허용. 원격(외부망)은 자체 릴레이 대신 **Tailscale 문서화**(Syncthing 커뮤니티 표준 패턴).
**데스크톱 UI 연결점**: 설정에 "모바일 연동" 섹션 → QR 다이얼로그(QRCoder) + 페어링된 기기 목록 + 원격 액세스 토글. 브라우저 계획(v2)의 명령 팔레트에 "기기로 보내기" 액션 추가 가능.
---
## 4. 모바일 앱 기능 목록 (Tier)
### Tier M1 — 최소 컴패니언 (Android APK)
- [ ] QR 스캔 페어링(ZXing.Net.Maui) + 수동 IP:포트 폴백
- [ ] 큐 목록 + 실시간 진행률(SignalR or 폴링) + 일시정지/재개/취소/재시도
- [ ] 공유 타겟: 다른 앱에서 URL 공유 → "PC에서 받기" (U1)
- [ ] 링크 붙여넣기 직접 추가
- [ ] 웹 컴패니언 페이지(데스크톱이 서빙) — 앱 없이 검증용
### Tier M2 — 라이브러리 + 시청
- [ ] 라이브러리 그리드(썸네일·제목·해상도·크기·추가일)
- [ ] MediaElement 스트리밍 재생(Range HTTP) — 백그라운드 오디오
- [ ] 이어보기 위치 동기화
- [ ] 폰으로 다운로드(오프라인 저장) + 다운로드 탭(Neflix식) + Android 진행 알림
- [ ] Android 재생중 미디어 알림(FFS mediaPlayback)
### Tier M3 — iOS 확장
- [ ] net10.0-ios 타겟 + Info.plist 키 3종(ATS/로컬네트워크/Bonjour)
- [ ] TestFlight 배포, "내 PC 라이브러리 리모컨" 프레이밍 (기기 내 추출 코드 엄격 배제)
### Tier M4 — Android 독립 다운로더 (선택, PC 없는 모드)
- [ ] Core HLS/DASH/직접 다운로더 + 모바일 muxer 어댑터
- [ ] YoutubeExplode로 YouTube 직접 수신
- [ ] WorkManager 백그라운드 큐 + 재시작 생존
- [ ] 내장 브라우저 탭(하단 주소창·탭 그리드·제스처 관례 준수, §1.1) + 감지 배지
- [ ] 저장: MediaStore.Downloads
### 시기상조/미차용 (명시적 제외)
- 클라우드 동기화/계정, 알림 미러링, 클립보드 동기, BLE 근접 전송, 자체 릴레이 서버, iOS 기기 내 yt-dlp(기술적·정책적 불가)
---
## 5. 로드맵 (의존성 순)
### Phase M0 — 데스크톱 준비 (필수 선행, 1~2주)
- [ ] **Core 추상화 리팩터**: `IProcessRunner`·`IToolLocator`·`IMediaMuxer`·경로 제공자 인터페이스 추출 (데스크톱 구현은 무변화, 기존 4개 프로젝트 무영향. BACKLOG Avalonia 로드맵 2단계와 동일 작업)
- [ ] **`VideoDownloader.Server` 신규 라이브러리**: Kestrel 내장 + 토큰 인증 + `/api/pair`·`/api/queue` + SignalR 허브
- [ ] WPF 설정에 QR 페어링 다이얼로그(QRCoder) + mDNS 광고(Makaretu.Dns)
- [ ] **검증 관문**: 폰 브라우저로 QR 스캔 → 웹 컴패니언에서 큐 조회/추가 동작 (앱 개발 전 서버 완성 입증)
- ⚠️ git 아님 → `git init` 후 착수 (브라우저 계획 Phase A와 동일 전제)
### Phase M1 — Android 컴패니언 앱 (2~3주)
- MAUI 10(net10.0-android) 신규 프로젝트 `VideoDownloader.Mobile` + Core 참조
- Tier M1 전체. **APK/GitHub Releases 배포** (Play 정책 회피 — YTDLnis 선례)
### Phase M2 — 라이브러리/시청 (2~3주)
- 데스크톱: ffprobe 메타데이터 + 썸네일 캐시, `/api/library`·`/stream/{id}`(Range)
- 앱: Tier M2 전체
### Phase M3 — iOS (1~2주 + 심사)
- Tier M3. TestFlight 우선.
### Phase M4 — Android 독립 모드 (선택, M2 이후 언제든)
- Tier M4. 브라우저 계획(BROWSER_FEATURES_PLAN v2)의 탭 아키텍처 경험을 모바일 WebView UI에 재활용.
**브라우저 계획과의 순서 권고**: 데스크톱 브라우저 기능(탭 등)과 모바일은 독립 트랙. 다만 **Core 추상화(M0-1)만 먼저** 해두면 양쪽이 자유로워짐. 서버(M0-2)는 브라우저 작업과 무관하게 병행 가능.
---
## 6. 리스크 & 대응
| 리스크 | 대응 |
|---|---|
| MAUI 품질(iOS 성능/바인딩 이슈 보고) | 컴패니언 규모(리스트·플레이어·스캐너)는 수용 가능한 범위. Android 먼저 출시해 리스크 분산 |
| iOS 5.2.2 리젝 (다운로더 오인) | 기기 내 추출 코드 배제 + "내 PC 리모컨" 프레이밍 + Brave 전례 문서화. TestFlight로 시작 |
| Windows 방화벽 첫 프롬프트 | 최초 비로컬 바인딩 시점에 안내 UI("허용" 가이드) |
| 멀티캐스트 차단 네트워크(기업/캠퍼스) | QR 페어링이 주 경로(발견 불필요) + 로컬 IP 프로브 폴백(LocalSend 방식) |
| iOS 로컬 네트워크 권한 거부 시 무음 실패 | 권한 거부 감지 → 설정 안내 화면 |
| Play 정책(독립 모드) | APK/GitHub 배포로 회피. Play 출시 시 사이트 특화 추출 코드 제거 빌드 |
| AVPlayer DASH 불가 | 컴패니언은 MP4/HLS 스트리밍만(앱 원본은 대부분 MP4). DASH 원본은 데스크톱에서 필요 시 ffmpeg 실시간 트랜스코드(후순위, Jellyfin 계층 구조 참고) |
| yt-dlp 인자 인젝션(YTDLnis RCE 전례, SonarSource) | U1 공유 URL 화이트리스트 검증 + 인자 배열 방식(이미 데스크톱 `OpenInExplorer`에서 동일 교훈 적용 완료) |
---
## 7. 성공 기준 (완료 정의)
- [ ] PC 앱 켠 상태에서 폰으로 QR 스캔 → 설치 없는 웹 컴패니언에서 큐 조회/URL 추가 성공 (M0)
- [ ] Android 앱: 공유 시트로 YouTube 링크 → PC 대기열 자동 추가 + PC 수락 → 다운로드 시작 (M1)
- [ ] Android 앱: 큐 진행률 실시간 갱신 + 일시정지/재개/취소 동작 (M1)
- [ ] 폰에서 PC 라이브러리 그리드 탐색 → 스트리밍 재생(탐색/시크 포함) → 앱 종료 후 재개 시 이어보기 (M2)
- [ ] 라이브러리 항목 폰 저장 → 오프라인 재생 (M2)
- [ ] iOS TestFlight 빌드 시동 + 페어링 + 큐 조회 (M3)
- [ ] `dotnet build` 전체 0 오류 — 기존 4개 프로젝트(WPF/Avalonia/Cli/Core) 회귀 없음 (M0~전체)
- [ ] 외부망에서 연결 시도 → 명확한 "같은 네트워크 필요/Tailscale 안내" 메시지 (UX)
---
## 출처 (핵심)
- **연동 패턴**: [LocalSend 프로토콜 v2.2](https://github.com/localsend/protocol) · [KDE Connect](https://github.com/kde/kdeconnect-kde) · [Firefox FxA 페어링 아키텍처](https://mozilla.github.io/ecosystem-platform/explanation/pairing-flow-architecture) · [Mozilla Sync E2E](https://hacks.mozilla.org/2018/11/firefox-sync-privacy/) · [Syncthing P2P 분석](https://advancedweb.hu/how-syncthing-provides-secure-file-syncing-without-sharing-your-files-with-a-third-party/) · [Intel Unison 종료](https://www.pcworld.com/article/2836520/intels-excellent-unison-pc-to-phone-app-shuts-down-for-good.html)
- **모바일 브라우저 UX**: [Chrome 하단 주소창](https://blog.google/products-and-platforms/products/chrome/address-bar-position-change/) · [Samsung Video Assistant](https://r2.community.samsung.com/t5/Tech-Talk/Samsung-Internet-web-browser-Video-Assistant/td-p/5923119) · [Brave Playlist](https://brave.com/playlist/) · [iOS 오프라인 금지 공지](https://community.brave.app/t/notice-playlist-offline-video-on-ios-disallowed-by-apple/654965)
- **플랫폼 제약**: [Apple 5.2.2 가이드라인](https://developer.apple.com/app-store/review/guidelines/) · [Android 14 FGS 타입](https://developer.android.com/about/versions/14/changes/fgs-types-required) · [iOS 백그라운드 다운로드](https://developer.apple.com/documentation/foundation/downloading-files-in-the-background) · [TN3179 로컬 네트워크 프라이버시](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy) · [AVPlayer HLS-only](https://mux.com/articles/hls-vs-dash-what-s-the-difference-between-the-video-streaming-protocols)
- **생태계**: [youtubedl-android](https://github.com/yausername/youtubedl-android) · [YTDLnis](https://github.com/deniscerri/ytdlnis) · [YTDLnis 인자 인젝션 RCE](https://www.sonarsource.com/blog/ytdlnis-argument-injection-rce/) · [Streamyfin](https://github.com/streamyfin/streamyfin) · [Jellyfin 트랜스코딩](https://jellyfin.org/docs/general/post-install/transcoding/) · [Documents by Readdle](https://support.readdle.com/documents/built-in-browser-download-manager/documents-internal-web-browser)
- **기술 스택**: [MAUI 지원 정책](https://dotnet.microsoft.com/en-us/platform/support/policy/maui) · [Rick Strahl: 데스크톱 내장 ASP.NET 서버](https://weblog.west-wind.com/posts/2023/Nov/27/Embedding-a-minimal-ASPNET-Web-Server-into-a-Desktop-Application) · [SignalR 지원 플랫폼](https://learn.microsoft.com/en-us/aspnet/core/signalr/supported-platforms) · [ZXing.Net.Maui](https://github.com/redth/ZXing.Net.Maui) · [MAUI MediaElement](https://learn.microsoft.com/en-us/dotnet/communitytoolkit/maui/views/mediaelement) · [Makaretu.Dns.Multicast](https://richardschneider.github.io/net-mdns/articles/sd.html) · [QRCoder](https://github.com/codebude/QRCoder) · [YoutubeExplode](https://github.com/tyrrrz/youtubeexplode) · [Kestrel Range 처리](https://github.com/dotnet/aspnetcore/issues/25230)

71
README.md Normal file
View file

@ -0,0 +1,71 @@
# 비디오 다운로더 (HLS / MP4)
maccms 계열 스트리밍 사이트(tvchak208 등)의 영상을 다운로드하는 도구입니다.
HLS(`.m3u8`) 분할 재생과 직접 MP4 모두 지원합니다.
## 기능
- **페이지 URL 자동 분석**: 재생페이지 URL만 넣으면 `player_aaaa` 설정 / 정규식 / iframe 플레이어에서 m3u8·mp4 주소를 자동 추출
- **HLS 완전 지원**: 마스터 재생목록(최고 화질 자동 선택), **AES-128 복호화**, **fMP4(`#EXT-X-MAP`)** 초기화 세그먼트
- **병렬 다운로드 + 이어받기**: 도중에 끊겨도 재실행 시 받은 분할은 건너뜀
- **ffmpeg 자동 리먹스**: TS / fMP4 → MP4 (`-c copy`, 무손실·빠름)
- **커스텀 헤더**: `--referer` 등으로 CDN 토큰 인증 대응
## 준비
```bash
pip install -r requirements.txt
# ffmpeg 권장 (https://ffmpeg.org). PATH에 있으면 자동 인식됨.
```
## 사용법
### 1) 가장 간단 — 페이지 URL만
```bash
python downloader.py "https://tvchak208.com/index.php/vod/play/id/123367/sid/1/nid/1.html" -o 영상제목
```
### 2) m3u8 주소를 직접 넘기기 (가장 확실, 아래 참고)
```bash
python downloader.py "https://cdn.example.com/.../index.m3u8" -o 영상제목 --referer "https://tvchak208.com/"
```
### 3) 추가 헤더가 필요한 경우
```bash
python downloader.py "https://.../index.m3u8" -o 영상제목 \
--referer "https://tvchak208.com/" \
-H "Origin: https://tvchak208.com"
```
옵션 요약
| 옵션 | 설명 |
|---|---|
| `-o, --output` | 출력 파일명 (확장자 생략 가능, 기본 `video`) |
| `--referer` | Referer 헤더. 403/401 날 때 페이지 주소를 지정 |
| `-H "K: V"` | 추가 헤더 (여러 개 가능) |
| `-c, --concurrency` | 동시 연결 수 (기본 8) |
| `--user-agent` | User-Agent 재정의 |
| `--keep-parts` | 병합 후 분할 임시파일 유지(디버그) |
---
## 이 사이트에서 m3u8 주소 직접 구하기 (추천)
페이지 자동 추출이 안 될 때(사이트마다 난독화가 다름) 가장 확실한 방법:
1. Chrome/Edge로 재생페이지 열기
2. `F12` → **Network(네트워크)** 탭
3. 필터에 **`m3u8`** 입력
4. 영상 **재생** 버튼 클릭
5. 목록에 뜬 `.m3u8` 요청 위에서 **우클릭 → Copy → Copy link address**
6. 그 주소를 다운로더에 넘김:
```bash
python downloader.py "복사한_m3u8_주소" -o 영상제목 --referer "https://tvchak208.com/"
```
> 팁: `index.m3u8`(마스터)과 `*.m3u8`(미디어)이 모두 뜰 수 있는데, 마스터를 넘겨도 **최고 화질을 자동 선택**합니다. 가장 위쪽 m3u8을 고르면 됩니다.
> 403/401 에러가 나면 반드시 `--referer`로 재생페이지 주소를 지정하세요.
## 문제 해결
- **`페이지에서 영상 주소를 찾지 못했습니다`** → 위 "m3u8 주소 직접 구하기" 방법 사용
- **403/401** → `--referer "페이지 주소"` 추가 (필요시 `-H "Origin: ..."` 도 함께)
- **`암호화 영상입니다. pycryptodome 필요`** → `pip install pycryptodome`
- **MP4로 안 변환됨** → ffmpeg 설치 확인 (`ffmpeg -version`)

View file

@ -0,0 +1,11 @@
<Application x:Class="VideoDownloader.App.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:VideoDownloader.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries/>
<local:InverseCountToVisibilityConverter x:Key="InverseCountToVisibility"/>
</ResourceDictionary>
</Application.Resources>
</Application>

View file

@ -0,0 +1,53 @@
using System.IO;
using System.Windows;
using System.Windows.Threading;
using VideoDownloader.App.Core;
namespace VideoDownloader.App;
public partial class App : Application
{
private static string CrashLogPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader", "crash.log");
// #14 글로벌 예외 핸들러: 잡히지 않은 예외를 crash.log 에 기록해 조용히 삼켜지는 것을 방지.
protected override void OnStartup(StartupEventArgs e)
{
DispatcherUnhandledException += (_, de) => { LogCrash(de.Exception); de.Handled = true; };
AppDomain.CurrentDomain.UnhandledException += (_, ue) => LogCrash(ue.ExceptionObject as Exception);
TaskScheduler.UnobservedTaskException += (_, ue) => { LogCrash(ue.Exception); ue.SetObserved(); };
base.OnStartup(e);
ApplyTheme(ConfigStore.Load().Theme);
new MainWindow().Show();
}
/// <summary>테마 색(dark/light) + Shared(스타일) 리소스사전을 App.Resources 에 주입.
/// 주의: 반드시 색(brush) 사전을 스타일 사전보다 먼저 로드해야 Shared 의 템플릿이
/// {StaticResource GlassBg} 등을 해석할 수 있다.</summary>
public static void ApplyTheme(string theme)
{
var dicts = Current.Resources.MergedDictionaries;
dicts.Clear();
var themeSrc = theme.Equals("light", StringComparison.OrdinalIgnoreCase)
? "pack://application:,,,/Themes/Light.xaml" : "pack://application:,,,/Themes/Dark.xaml";
dicts.Add(new ResourceDictionary { Source = new Uri(themeSrc, UriKind.Absolute) });
dicts.Add(new ResourceDictionary { Source = new Uri("pack://application:,,,/Themes/Shared.xaml", UriKind.Absolute) });
}
/// <summary>현재 적용된 테마가 라이트인지 (DWM 타이틀바 색 연동용).</summary>
public static bool IsLightTheme => ConfigStore.Load().Theme.Equals("light", StringComparison.OrdinalIgnoreCase);
private static void LogCrash(Exception? ex)
{
if (ex == null) return;
try
{
var dir = Path.GetDirectoryName(CrashLogPath);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.AppendAllText(CrashLogPath,
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {ex}{Environment.NewLine}{new string('-', 60)}{Environment.NewLine}");
}
catch { }
}
}

View file

@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

View file

@ -0,0 +1,5 @@
namespace VideoDownloader.App.Browser;
public record Bookmark(string Title, string Url);
public record HistoryEntry(string Title, string Url, DateTime VisitedAt);

View file

@ -0,0 +1,68 @@
using System.Text.Json;
namespace VideoDownloader.App.Browser;
/// <summary>즐겨찾기/히스토리 JSON 영속화 (%AppData%/VideoDownloader).
/// #5 저장/로드 실패를 조용 삼킴하지 않고 browserstore.log 에 기록하며,
/// 손상 파일은 백업 후 빈 목록을 반환(다음 저장이 원본을 덮어쓰는 데이터 손실 방지).</summary>
public static class BrowserStore
{
private static string Dir => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader");
private static string BookmarksPath => Path.Combine(Dir, "bookmarks.json");
private static string HistoryPath => Path.Combine(Dir, "history.json");
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
public static List<Bookmark> LoadBookmarks()
{
try { if (File.Exists(BookmarksPath)) return JsonSerializer.Deserialize<List<Bookmark>>(File.ReadAllText(BookmarksPath)) ?? new(); }
catch (Exception e) { LogLoadFail(BookmarksPath, e); }
return new();
}
public static void SaveBookmarks(List<Bookmark> bookmarks)
{
try { Directory.CreateDirectory(Dir); File.WriteAllText(BookmarksPath, JsonSerializer.Serialize(bookmarks, JsonOpts)); }
catch (Exception e) { LogFail("저장", BookmarksPath, e); } // 디스크 가득/권한/잠금 → UI 스레드 전파 대신 로그
}
public static List<HistoryEntry> LoadHistory()
{
try { if (File.Exists(HistoryPath)) return JsonSerializer.Deserialize<List<HistoryEntry>>(File.ReadAllText(HistoryPath)) ?? new(); }
catch (Exception e) { LogLoadFail(HistoryPath, e); }
return new();
}
public static void SaveHistory(List<HistoryEntry> history)
{
try { Directory.CreateDirectory(Dir); File.WriteAllText(HistoryPath, JsonSerializer.Serialize(history, JsonOpts)); }
catch (Exception e) { LogFail("저장", HistoryPath, e); }
}
/// <summary>로드 실패(손상/권한/잠금) 시 원본을 .corrupt 백업한 뒤 로그.
/// 빈 목록을 반환하더라도 원본이 남아 다음 저장이 사용자 데이터를 덮어쓰지 않게 한다.</summary>
private static void LogLoadFail(string path, Exception e)
{
try
{
if (File.Exists(path))
File.Move(path, $"{path}.corrupt.{DateTime.Now:yyyyMMddHHmmss}.bak", overwrite: true);
}
catch { /* best-effort 백업 — 실패해도 계속 진행 */ }
LogFail("로드", path, e);
}
private static void LogFail(string op, string path, Exception e)
=> Log($"{op} 실패({Path.GetFileName(path)}): {e.GetType().Name}: {e.Message}");
private static void Log(string msg)
{
try
{
Directory.CreateDirectory(Dir);
File.AppendAllText(Path.Combine(Dir, "browserstore.log"),
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {msg}{Environment.NewLine}");
}
catch { /* 로그마저 실패하면 조용히 */ }
}
}

View file

@ -0,0 +1,15 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace VideoDownloader.App;
/// <summary>컬렉션 카운트 → 빈 상태 표시용 (0일 때 Visible)</summary>
public class InverseCountToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> (value is int i && i > 0) ? Visibility.Collapsed : Visibility.Visible;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}

View file

@ -0,0 +1,35 @@
using System.Text.Json;
using VideoDownloader.Core.Config;
namespace VideoDownloader.App.Core;
/// <summary>%AppData%/VideoDownloader/config.json 으로 설정 영속화</summary>
public static class ConfigStore
{
private static string Dir => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader");
private static string FilePath => Path.Combine(Dir, "config.json");
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = true };
public static AppConfig Load()
{
try
{
if (File.Exists(FilePath))
return JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(FilePath)) ?? new AppConfig();
}
catch { /* 손상 시 기본값 */ }
return new AppConfig();
}
public static void Save(AppConfig config)
{
try
{
Directory.CreateDirectory(Dir);
File.WriteAllText(FilePath, JsonSerializer.Serialize(config, JsonOpts));
}
catch { /* 저장 실패는 무시 */ }
}
}

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;
}
}

View file

@ -0,0 +1,6 @@
namespace VideoDownloader.App.Detection;
public enum MediaKind { Hls, Dash, Direct, Social }
/// <summary>감지된 미디어. HLS(m3u8) / DASH(.mpd) / 직링크(Direct mp4/webm/...) / Social(yt-dlp).</summary>
public record DetectionContext(string Url, string PageUrl, MediaKind Kind, string? ContentType = null, string? Title = null, string? Tag = null);

View file

@ -0,0 +1,81 @@
using VideoDownloader.Core.Config;
namespace VideoDownloader.App.Detection;
/// <summary>요청(URL + content-type + 길이)과 설정으로 미디어 종류를 판정.</summary>
public static class MediaDetector
{
/// <returns>HLS 또는 Direct, 감지 대상이 아니면 null</returns>
public static MediaKind? Classify(string? uri, string? contentType, long len, AppConfig config)
{
var l = (uri ?? "").ToLowerInvariant();
var c = (contentType ?? "").ToLowerInvariant();
bool isHls = c.Contains("mpegurl") || l.Contains(".m3u8") || l.Contains(".m3u");
if (isHls)
return Enabled(config, "hls") ? MediaKind.Hls : null;
bool isDash = c.Contains("dash+xml") || l.Contains(".mpd");
if (isDash)
return Enabled(config, "dash") ? MediaKind.Dash : null;
long min = config.MinSizeMB * 1024L * 1024L;
if (len >= 0 && len <= min) return null; // 너무 작음
var fmt = MatchFormat(l, c);
if (fmt == null) return null;
return Enabled(config, fmt) ? MediaKind.Direct : null;
}
private static bool Enabled(AppConfig config, string key) => config.EnabledFormats.Contains(key);
/// <summary>형식 키 반환: mp4/webm/mkv/m4v/mov/avi/audio (미매칭 null)</summary>
public static string? MatchFormat(string l, string c)
{
if (l.EndsWith(".mp4") || c.Contains("mp4")) return "mp4";
if (l.EndsWith(".webm") || c.Contains("webm")) return "webm";
if (l.EndsWith(".mkv") || c.Contains("matroska")) return "mkv";
if (l.EndsWith(".m4v")) return "m4v";
if (l.EndsWith(".mov") || c.Contains("quicktime")) return "mov";
if (l.EndsWith(".avi") || c.Contains("x-msvideo")) return "avi";
bool audio = c.StartsWith("audio/") || l.EndsWith(".m4a") || l.EndsWith(".mp3")
|| l.EndsWith(".ogg") || l.EndsWith(".opus") || l.EndsWith(".flac")
|| l.EndsWith(".wav") || l.EndsWith(".aac");
if (audio) return "audio";
if (c.StartsWith("video/")) return "mp4"; // 기타 video 는 mp4 취급
return null;
}
/// <summary>감지 후보(사람에게 보여줄 만한) 판정 - DASH/MSE/세그먼트 등</summary>
public static bool LooksLikeCandidate(string l, string c) =>
l.Contains(".m4s") || l.Contains("segment") || l.Contains("/init") || l.Contains(".ts?")
|| l.Contains(".mpd") || c.Contains("dash+xml") || c.Contains("mp2t") || c.Contains("octet-stream");
/// <summary>#15 본문 선두 magic byte 로 실제 포맷 판별 (위장 image/* 등 검증용).
/// 이미지 포맷(GIF/PNG/JPEG/WebP)은 null 반환 — 위장 트래킹 픽셀이 미디어로 오탐되는 것 방지.
/// (GIF 'G'=0x47 가 MPEG-TS sync byte 와 겹쳐 ts 로 오탐되던 문제 수정)</summary>
public static string? ProbeFormat(ReadOnlySpan<byte> h)
{
if (h.Length < 4) return null;
// 이미지 포맷 → 미디어 아님
if (h.Length >= 3 && h[0] == 'G' && h[1] == 'I' && h[2] == 'F') return null; // GIF
if (h.Length >= 8 && h[0] == 0x89 && h[1] == 0x50 && h[2] == 0x4E && h[3] == 0x47) return null; // PNG
if (h.Length >= 3 && h[0] == 0xFF && h[1] == 0xD8 && h[2] == 0xFF) return null; // JPEG
if (h.Length >= 12 && h[0] == 0x52 && h[1] == 0x49 && h[2] == 0x46 && h[3] == 0x46) // RIFF
{
if (h[8] == 'W' && h[9] == 'E' && h[10] == 'B' && h[11] == 'P') return null; // WebP(이미지)
if (h[8] == 'W' && h[9] == 'A' && h[10] == 'V' && h[11] == 'E') return "wav"; // WAV(오디오)
return null;
}
if (h.Length >= 12 && h[4] == 'f' && h[5] == 't' && h[6] == 'y' && h[7] == 'p') return "mp4"; // ftyp box
if (h[0] == 0x47) return "ts"; // MPEG-TS sync (GIF 는 위에서 걸러짐)
if (h[0] == 0x1A && h[1] == 0x45 && h[2] == 0xDF && h[3] == 0xA3) return "webm"; // EBML
if (h[0] == 'I' && h[1] == 'D' && h[2] == '3') return "mp3"; // ID3
if (h[0] == 0xFF && (h[1] & 0xE0) == 0xE0) return "mp3"; // MP3 frame
if (h[0] == 0x66 && h[1] == 0x4C && h[2] == 0x61 && h[3] == 0x43) return "flac"; // fLaC
if (h[0] == 0x4F && h[1] == 0x67 && h[2] == 0x67 && h[3] == 0x53) return "ogg"; // OggS
return null;
}
}

View file

@ -0,0 +1,136 @@
using System.Text.RegularExpressions;
namespace VideoDownloader.App.Detection;
/// <summary>
/// 소셜 영상 페이지(YouTube/Shorts/Instagram/X/TikTok/Facebook) 판별 + URL 정규화.
/// yt-dlp 로 처리 가능한 단일 영상 URL 인지 검사하고, 같은 영상의 여러 URL 형태를
/// 하나의 정규화된 키로 통일(중복 등록 방지). 홈/검색/채널 페이지는 거짓.
/// </summary>
public static class SocialSiteDetector
{
private static readonly Regex YtId = new(@"^[A-Za-z0-9_-]{11}$", RegexOptions.Compiled);
/// <summary>소셜 영상 URL 이면 정규화된 URL 과 사이트 라벨을 반환.</summary>
public static bool TryMatch(Uri url, out string normalized, out string label)
{
normalized = "";
label = "";
var host = (url.Host ?? "").ToLowerInvariant();
var path = url.AbsolutePath ?? "";
// ===== YouTube / Shorts =====
if (host.EndsWith("youtube.com") || host == "m.youtube.com" || host == "youtu.be")
{
string? id = ExtractYouTubeId(host, url, path);
if (id != null && YtId.IsMatch(id))
{
normalized = $"https://www.youtube.com/watch?v={id}";
label = "YOUTUBE";
return true;
}
return false;
}
// ===== Instagram =====
if (host.EndsWith("instagram.com"))
{
// /reel/<id>/ , /reels/<id>/ , /p/<id>/ , /tv/<id>/ , /stories/<u>/<id>/
var m = Regex.Match(path, "/(?:reels?|p|tv)/([^/?#]+)");
if (!m.Success) m = Regex.Match(path, "/stories/([^/]+)/([^/?#]+)");
if (m.Success)
{
normalized = Normalize(host, m.Value); // 매칭된 경로만 보존
label = "INSTAGRAM";
return true;
}
return false;
}
// ===== X / Twitter =====
if (host == "x.com" || host == "twitter.com" || host.EndsWith(".x.com") || host.EndsWith(".twitter.com"))
{
var m = Regex.Match(path, "/[^/]+/status/([0-9]+)");
if (m.Success)
{
normalized = Normalize(host, m.Value);
label = "X";
return true;
}
return false;
}
// ===== TikTok =====
if (host.EndsWith("tiktok.com"))
{
var m = Regex.Match(path, "/@[^/]+/video/([0-9]+)");
if (m.Success)
{
normalized = Normalize(host, m.Value);
label = "TIKTOK";
return true;
}
return false;
}
// ===== Facebook =====
if (host.EndsWith("facebook.com") || host == "fb.watch")
{
var m = Regex.Match(path, "/(?:reel|videos)/([^/?#]+)");
string? watchId = GetQuery(url, "v");
if (m.Success)
{
normalized = Normalize(host == "fb.watch" ? "www.facebook.com" : host, m.Value);
label = "FACEBOOK";
return true;
}
if (!string.IsNullOrEmpty(watchId))
{
normalized = $"https://www.facebook.com/watch/?v={watchId}";
label = "FACEBOOK";
return true;
}
return false;
}
return false;
}
private static string? ExtractYouTubeId(string host, Uri url, string path)
{
if (host == "youtu.be")
{
var seg = path.Trim('/');
return string.IsNullOrEmpty(seg) ? null : seg;
}
var v = GetQuery(url, "v");
if (!string.IsNullOrEmpty(v)) return v;
var m = Regex.Match(path, "/(?:shorts|embed|live)/([A-Za-z0-9_-]{11})");
if (m.Success) return m.Groups[1].Value;
return null;
}
/// <summary>쿼리스트링에서 단일 키 값 추출(? 제외 후 & 와 = 로 분해).</summary>
private static string? GetQuery(Uri url, string key)
{
var query = url.Query ?? "";
if (query.StartsWith("?")) query = query[1..];
if (string.IsNullOrEmpty(query)) return null;
foreach (var pair in query.Split('&'))
{
var eq = pair.IndexOf('=');
if (eq <= 0) continue;
var k = pair[..eq];
if (k.Equals(key, StringComparison.Ordinal))
return Uri.UnescapeDataString(pair[(eq + 1)..]);
}
return null;
}
/// <summary>scheme://host + 매칭된 경로(쿼리/트래킹 제거). host 에 www 보정.</summary>
private static string Normalize(string host, string path)
{
var h = host.StartsWith("www.") ? host : "www." + host;
return $"https://{h}{path}";
}
}

View file

@ -0,0 +1,399 @@
<Window x:Class="VideoDownloader.App.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
xmlns:vm="clr-namespace:VideoDownloader.App.ViewModels"
mc:Ignorable="d"
FontFamily="{StaticResource UiFont}"
Title="Video Downloader" Height="820" Width="1300"
Background="{StaticResource WindowBgGradient}"
WindowStartupLocation="CenterScreen"
WindowStyle="None" ResizeMode="CanResize">
<!-- 보더리스: WindowChrome 이 실제 엣지 리사이즈 + 올바른 최대화(작업표시줄 존중) + DWM 그림자 담당 -->
<WindowChrome.WindowChrome>
<WindowChrome CaptionHeight="0"
ResizeBorderThickness="6"
GlassFrameThickness="1"
CornerRadius="0"
UseAeroCaptionButtons="False"
NonClientFrameEdges="None"/>
</WindowChrome.WindowChrome>
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
<DataTemplate DataType="{x:Type vm:DownloadItemViewModel}">
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="1" CornerRadius="12" Padding="13" Margin="0,0,0,10">
<StackPanel>
<DockPanel>
<Border DockPanel.Dock="Right" Background="{StaticResource GlassBg}"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1"
CornerRadius="6" Padding="8,2" VerticalAlignment="Center">
<TextBlock Text="{Binding FormatTag}" FontSize="10" FontWeight="Bold"
Foreground="{StaticResource AccentBrush}"/>
</Border>
<TextBlock Text="{Binding Title}" Foreground="{StaticResource TextPrimary}"
FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center"/>
</DockPanel>
<!-- 소셜(YouTube/Instagram/X) 카드: 화질 선택 -->
<DockPanel Margin="0,9,0,2" Visibility="{Binding IsSocial, Converter={StaticResource BoolToVis}}">
<Button DockPanel.Dock="Right" Click="CardFetchFormats_Click" Margin="7,0,0,0"
VerticalAlignment="Center" ToolTip="이 영상에서 사용 가능한 화질 조회">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource CardActionButton}">
<Setter Property="Content" Value="불러오기"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsFetchingFormats}" Value="True">
<Setter Property="Content" Value="…"/>
<Setter Property="IsEnabled" Value="False"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="다운로드 중"><Setter Property="IsEnabled" Value="False"/></DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="병합 중"><Setter Property="IsEnabled" Value="False"/></DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="리먹스 중"><Setter Property="IsEnabled" Value="False"/></DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="완료"><Setter Property="IsEnabled" Value="False"/></DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<ComboBox Style="{StaticResource DarkComboBox}"
ItemsSource="{Binding Formats}" DisplayMemberPath="Label"
SelectedItem="{Binding SelectedFormat}"
VerticalAlignment="Center"/>
</DockPanel>
<DockPanel Margin="0,11,0,6">
<TextBlock DockPanel.Dock="Right" Text="{Binding Segments}"
Foreground="{StaticResource TextMuted}" FontSize="11"
VerticalAlignment="Center" Margin="8,0,0,0"/>
<Grid VerticalAlignment="Center">
<ProgressBar Value="{Binding Progress}" Maximum="100">
<ProgressBar.Style>
<Style TargetType="ProgressBar" BasedOn="{StaticResource AccentProgressBar}">
<Style.Triggers>
<DataTrigger Binding="{Binding IsIndeterminate}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ProgressBar.Style>
</ProgressBar>
<ProgressBar>
<ProgressBar.Style>
<Style TargetType="ProgressBar" BasedOn="{StaticResource IndeterminateBar}">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding IsIndeterminate}" Value="True">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</ProgressBar.Style>
</ProgressBar>
</Grid>
</DockPanel>
<DockPanel Margin="0,4,0,0">
<TextBlock Text="{Binding Status}" FontSize="11" VerticalAlignment="Center">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextSecondary}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="오류">
<Setter Property="Foreground" Value="{StaticResource ErrorBrush}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="완료">
<Setter Property="Foreground" Value="{StaticResource SuccessBrush}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button Style="{StaticResource CardActionButton}" Content="받기" Click="CardDownload_Click" Margin="0,0,6,0"/>
<Button Content="취소" Click="CardCancel_Click" Margin="0,0,6,0">
<Button.Style>
<Style TargetType="Button" BasedOn="{StaticResource CardActionButton}">
<Setter Property="IsEnabled" Value="False"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="다운로드 중"><Setter Property="IsEnabled" Value="True"/></DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="병합 중"><Setter Property="IsEnabled" Value="True"/></DataTrigger>
<DataTrigger Binding="{Binding Status}" Value="리먹스 중"><Setter Property="IsEnabled" Value="True"/></DataTrigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button Style="{StaticResource CardActionButton}" Content="재시도" Click="CardRetry_Click" Margin="0,0,6,0"/>
<Button Style="{StaticResource CardActionButton}" Content="폴더" Click="CardFolder_Click"/>
</StackPanel>
</DockPanel>
<StackPanel Orientation="Horizontal" Margin="0,8,0,0">
<StackPanel.Style>
<Style TargetType="StackPanel">
<Setter Property="Visibility" Value="Collapsed"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Status}" Value="오류">
<Setter Property="Visibility" Value="Visible"/>
</DataTrigger>
</Style.Triggers>
</Style>
</StackPanel.Style>
<TextBlock Text="&#xE7BA;" Style="{StaticResource FluentIcon}" Foreground="{StaticResource ErrorBrush}" FontSize="13" Margin="0,0,6,0"/>
<TextBlock Text="{Binding Error}" Foreground="{StaticResource ErrorBrush}" FontSize="11" TextWrapping="Wrap" MaxWidth="300"/>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</Window.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- ===== 상단 툴바 (슬림, 전체가 드래그 핸들) ===== -->
<Border Grid.Row="0" Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource AccentFadeBrush}" BorderThickness="0,0,0,2" Padding="20,9,0,9"
PreviewMouseLeftButtonDown="TitleBar_PreviewMouseLeftButtonDown">
<DockPanel>
<!-- 브랜드 -->
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal"
VerticalAlignment="Center" Margin="0,0,12,0">
<Border Width="26" Height="26" CornerRadius="7" Background="{StaticResource AccentGradient}">
<TextBlock Text="&#xE896;" Style="{StaticResource FluentIcon}" Foreground="White" FontSize="14"/>
</Border>
<TextBlock Text="Video Downloader" Foreground="{StaticResource TextPrimary}"
FontWeight="SemiBold" FontSize="13.5" VerticalAlignment="Center" Margin="9,0,0,0"/>
</StackPanel>
<!-- 네비게이션 (Fluent 아이콘) -->
<StackPanel DockPanel.Dock="Left" Orientation="Horizontal" VerticalAlignment="Center">
<Button Name="NavBack" Style="{StaticResource IconButton}" Content="&#xE76B;" ToolTip="뒤로 (Alt+←)" Margin="0,0,3,0" Click="NavBack_Click"/>
<Button Name="NavForward" Style="{StaticResource IconButton}" Content="&#xE76C;" ToolTip="앞으로 (Alt+→)" Margin="0,0,3,0" Click="NavForward_Click"/>
<Button Name="NavRefresh" Style="{StaticResource IconButton}" Content="&#xE72C;" ToolTip="새로고침 (F5)" Margin="0,0,3,0" Click="NavRefresh_Click"/>
<Button Name="NavHome" Style="{StaticResource IconButton}" Content="&#xE80F;" ToolTip="홈" Click="NavHome_Click"/>
</StackPanel>
<!-- 우측 액션 + 캡션 (Fluent 아이콘 + 툴팁) -->
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center" Margin="10,0,0,0">
<Button Name="BookmarksBtn" Style="{StaticResource IconButton}" Content="&#xE734;" ToolTip="즐겨찾기" Margin="0,0,2,0" Click="ToggleBookmarks"/>
<Button Name="HistoryBtn" Style="{StaticResource IconButton}" Content="&#xE823;" ToolTip="방문 기록" Margin="0,0,2,0" Click="ToggleHistory"/>
<Button Style="{StaticResource IconButton}" Content="&#xE8B7;" ToolTip="저장 폴더 열기" Margin="0,0,2,0" Click="OpenFolder_Click"/>
<Button Name="LinkBtn" Style="{StaticResource IconButton}" Content="&#xE71B;" ToolTip="링크로 받기 (YouTube·Instagram·X)" Margin="0,0,2,0" Click="ToggleLink"/>
<Button x:Name="PrivateBtn" Style="{StaticResource IconButton}" Content="&#xE72E;" ToolTip="개인정보 보호 모드(InPrivate)" Margin="0,0,2,0" Click="TogglePrivate_Click"/>
<Button Style="{StaticResource IconButton}" Content="&#xE713;" ToolTip="설정" Margin="0,0,8,0" Click="Settings_Click"/>
<Button Style="{StaticResource PrimaryButton}" Content="이동" ToolTip="주소로 이동 (Enter)" Height="32" Click="Go_Click"/>
<!-- 구분선 (좌우 대칭 여백) -->
<Border Width="1" Background="{StaticResource BorderSubtle}" Margin="10,8"/>
<!-- 윈도우 캡션 컨트롤 (고정폭, 서로 밀착) -->
<Button Style="{StaticResource CaptionButton}" Content="&#xE921;" ToolTip="최소화" Click="Minimize_Click"/>
<Button Name="MaxRestoreBtn" Style="{StaticResource CaptionButton}" Content="&#xE922;" ToolTip="최대화" Click="MaxRestore_Click"/>
<Button Style="{StaticResource CloseButton}" Content="&#xE8BB;" ToolTip="닫기" Click="Close_Click"/>
</StackPanel>
<!-- 주소창 -->
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="8" Height="32" VerticalAlignment="Center">
<TextBox Name="AddressBox" Style="{StaticResource DarkTextBox}"
PreviewKeyDown="AddressBox_PreviewKeyDown" TextChanged="AddressBox_TextChanged"
BorderThickness="0" Background="Transparent"
Padding="14,0" FontSize="13" VerticalContentAlignment="Center"/>
</Border>
</DockPanel>
</Border>
<!-- ===== 본문 ===== -->
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition x:Name="RightCol" Width="420" MinWidth="56" MaxWidth="900"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" ClipToBounds="True" Background="Black">
<wv2:WebView2 Name="Browser"/>
</Border>
<!-- 리사이즈 핸들 (펼침 상태에서만 노출). 드래그로 우측 패널 폭 변경. -->
<GridSplitter x:Name="RightSplitter" Grid.Column="1" Width="7"
HorizontalAlignment="Center" VerticalAlignment="Stretch"
ResizeBehavior="PreviousAndNext" ResizeDirection="Columns"
Background="Transparent" Cursor="SizeWE"
DragCompleted="RightSplitter_DragCompleted">
<GridSplitter.Template>
<ControlTemplate TargetType="GridSplitter">
<Border x:Name="handle" Background="Transparent">
<Rectangle Width="1" HorizontalAlignment="Center" Fill="{StaticResource BorderSubtle}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="handle" Property="Background" Value="{StaticResource AccentFadeBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</GridSplitter.Template>
</GridSplitter>
<!-- 범용 우측 패널: 헤더(토글 버튼, 항상 노출) + 본문(펼침 시). 접기/펼치기·폭·상태 저장. -->
<Border Grid.Column="2" Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="1,0,0,0">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<DockPanel Grid.Row="0" Margin="10,12,10,8" LastChildFill="True">
<Button x:Name="PanelToggle" DockPanel.Dock="Right" Style="{StaticResource IconButton}"
Click="PanelToggle_Click" ToolTip="패널 접기"
VerticalAlignment="Center" Margin="4,0,0,0" Content="&#xE76C;"/>
<Border x:Name="PanelCountBadge" DockPanel.Dock="Right" Background="{StaticResource GlassBg}"
CornerRadius="9" Padding="9,2" HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,8,0">
<TextBlock Text="{Binding Items.Count}" Foreground="{StaticResource TextSecondary}" FontSize="11"/>
</Border>
<TextBlock x:Name="PanelTitle" Text="다운로드" Foreground="{StaticResource TextPrimary}"
FontWeight="SemiBold" FontSize="13.5" VerticalAlignment="Center"/>
</DockPanel>
<Grid Grid.Row="1" x:Name="PanelBody">
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Center" Margin="0,60,0,0"
Visibility="{Binding Items.Count, Converter={StaticResource InverseCountToVisibility}}">
<Border Width="54" Height="54" CornerRadius="14" Background="{StaticResource GlassBg}"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" HorizontalAlignment="Center">
<TextBlock Text="&#xE8B2;" Style="{StaticResource FluentIcon}" FontSize="24" Foreground="{StaticResource TextMuted}"/>
</Border>
<TextBlock Text="영상을 재생하면 여기에 나타납니다" Foreground="{StaticResource TextSecondary}"
FontSize="13" FontWeight="Medium" HorizontalAlignment="Center" Margin="0,14,0,2"/>
<TextBlock Text="탐지 즉시 자동으로 목록에 추가돼요" Foreground="{StaticResource TextMuted}"
FontSize="11.5" HorizontalAlignment="Center"/>
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto" Padding="16,2,16,16">
<ItemsControl ItemsSource="{Binding Items}"/>
</ScrollViewer>
</Grid>
</Grid>
</Border>
</Grid>
<!-- ===== 상태 표시줄 ===== -->
<Border Grid.Row="2" Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="16,5">
<TextBlock Name="StatusText" Foreground="{StaticResource TextMuted}" FontSize="11"
TextTrimming="CharacterEllipsis" Text="준비"/>
</Border>
<!-- ===== 즐겨찾기 플라이아웃 ===== -->
<Popup Name="BookmarksPopup" PlacementTarget="{Binding ElementName=BookmarksBtn}"
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="12" Padding="8" Width="320" MaxHeight="460">
<StackPanel>
<DockPanel Margin="6,4,6,6">
<TextBlock Text="즐겨찾기" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold" VerticalAlignment="Center"/>
<Button DockPanel.Dock="Right" Style="{StaticResource CardActionButton}" Content="+ 현재 페이지 추가" Click="AddBookmark_Click" HorizontalAlignment="Right"/>
</DockPanel>
<Border Background="{StaticResource BorderSubtle}" Height="1" Margin="0,0,0,4"/>
<ItemsControl Name="BookmarksList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,1">
<Button DockPanel.Dock="Right" Style="{StaticResource CardActionButton}" Content="✕"
Click="RemoveBookmark_Click" Padding="8,5" Margin="6,0,0,0" VerticalAlignment="Center"/>
<Button Style="{StaticResource FlatButton}" Click="OpenBookmark_Click" HorizontalAlignment="Stretch">
<StackPanel>
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis" FontSize="12.5" FontWeight="Medium"/>
<TextBlock Text="{Binding Url}" Foreground="{StaticResource TextMuted}" FontSize="10.5" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Button>
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</Popup>
<!-- ===== 히스토리 플라이아웃 ===== -->
<Popup Name="HistoryPopup" PlacementTarget="{Binding ElementName=HistoryBtn}"
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="12" Padding="8" Width="340" MaxHeight="480">
<StackPanel>
<DockPanel Margin="6,4,6,6">
<TextBlock Text="방문 기록" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold" VerticalAlignment="Center"/>
<Button DockPanel.Dock="Right" Style="{StaticResource CardActionButton}" Content="🗑 전체 삭제" Click="ClearHistory_Click" HorizontalAlignment="Right"/>
</DockPanel>
<Border Background="{StaticResource BorderSubtle}" Height="1" Margin="0,0,0,4"/>
<ScrollViewer VerticalScrollBarVisibility="Auto" MaxHeight="400">
<ItemsControl Name="HistoryList">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Style="{StaticResource FlatButton}" Click="OpenHistory_Click" HorizontalAlignment="Stretch" Margin="0,1">
<StackPanel>
<DockPanel>
<TextBlock DockPanel.Dock="Right" Text="{Binding VisitedAt, StringFormat=HH:mm}" Foreground="{StaticResource TextMuted}" FontSize="10.5" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis" FontSize="12.5" FontWeight="Medium"/>
</DockPanel>
<TextBlock Text="{Binding Url}" Foreground="{StaticResource TextMuted}" FontSize="10.5" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Border>
</Popup>
<!-- ===== 주소창 자동완성 제안 ===== -->
<Popup Name="AddressPopup" PlacementTarget="{Binding ElementName=AddressBox}"
Placement="Bottom" StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade"
Width="560" MaxHeight="380">
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="10" Padding="4">
<ListBox Name="AddressSuggestions" Style="{StaticResource SuggestList}"
ItemContainerStyle="{StaticResource SuggestItem}"
PreviewMouseLeftButtonUp="AddressSuggestion_Click">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding Title}" TextTrimming="CharacterEllipsis" FontSize="12.5" FontWeight="Medium"/>
<TextBlock Text="{Binding Url}" Foreground="{StaticResource TextMuted}" FontSize="10.5" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
</Popup>
<!-- ===== 링크로 받기 플라이아웃 (YouTube/Instagram/X URL 붙여넣기) ===== -->
<Popup Name="LinkPopup" PlacementTarget="{Binding ElementName=LinkBtn}"
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
BorderThickness="1" CornerRadius="12" Padding="10" Width="380">
<StackPanel>
<TextBlock Text="링크로 받기" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold" Margin="6,2,6,4"/>
<TextBlock Text="YouTube·Instagram·X 등 영상 링크를 붙여넣고 받기를 누르세요" Foreground="{StaticResource TextMuted}" FontSize="11" Margin="6,0,6,10" TextWrapping="Wrap"/>
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource PrimaryButton}" Content="받기" Margin="8,0,0,0" Click="LinkDownload_Click"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="8">
<TextBox Name="LinkBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"
Padding="12,8" PreviewKeyDown="LinkBox_PreviewKeyDown"/>
</Border>
</DockPanel>
</StackPanel>
</Border>
</Popup>
</Grid>
</Window>

View file

@ -0,0 +1,764 @@
using System.Collections.ObjectModel;
using System.Net.Http.Headers;
using System.Runtime.InteropServices;
using System.Threading;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using Microsoft.Web.WebView2.Core;
using VideoDownloader.App.Browser;
using VideoDownloader.App.Core;
using VideoDownloader.App.Detection;
using VideoDownloader.App.ViewModels;
using VideoDownloader.Core.Config;
namespace VideoDownloader.App;
public partial class MainWindow : Window
{
private readonly DownloadManager _manager;
private AppConfig _config;
private int _totalRequests;
private string _appLogPath = "";
private string _currentTitle = "";
private readonly ObservableCollection<Bookmark> _bookmarks = new();
private readonly ObservableCollection<HistoryEntry> _history = new();
private string? _pendingHistoryUrl;
private string? _pendingSocialUrl; // 타이틀 확정 시 소셜 카드 제목 갱신용
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
// #15 위장 미디어(image/png 등) magic byte 검증용. 세션당 상한 두어 폭주 방지.
private static readonly HttpClient ProbeClient = new(new HttpClientHandler { AllowAutoRedirect = true })
{ Timeout = TimeSpan.FromSeconds(15) };
private readonly HashSet<string> _probed = new();
private int _probeCount;
// 범용 우측 패널: 접기/펼치기 + 폭 리사이즈 상태(설정에 저장)
private bool _panelCollapsed;
private double _panelExpandedWidth = 420;
private const double CollapsedPanelWidth = 56;
private const double MinExpandedWidth = 280;
private const double MaxExpandedWidth = 900;
public MainWindow()
{
InitializeComponent();
_config = ConfigStore.Load();
Directory.CreateDirectory(_config.OutputFolder);
_appLogPath = Path.Combine(_config.OutputFolder, "app.log");
try { File.WriteAllText(_appLogPath, $"=== 시작 {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==={Environment.NewLine}"); } catch { }
_manager = new DownloadManager(_config, GetCookiesAsync, Log);
DataContext = _manager;
foreach (var b in BrowserStore.LoadBookmarks()) _bookmarks.Add(b);
foreach (var h in BrowserStore.LoadHistory()) _history.Add(h);
BookmarksList.ItemsSource = _bookmarks;
HistoryList.ItemsSource = _history;
Loaded += async (_, _) => await InitializeBrowserAsync();
PreviewKeyDown += OnPreviewKeyDown;
StateChanged += OnWindowStateChanged;
AddressBox.LostKeyboardFocus += (_, _) => AddressPopup.IsOpen = false;
// 범용 우측 패널: 저장된 접힘/폭 상태 복원
_panelExpandedWidth = ClampPanelWidth(_config.RightPanelWidth);
ApplyPanelState(_config.RightPanelCollapsed);
Closing += OnWindowClosing;
}
// 보더리스: Win11 어두운 타이틀바 + 둥근 모서리(DWMWA_WINDOW_CORNER_PREFERENCE)
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
try
{
var hwnd = new WindowInteropHelper(this).Handle;
int dark = App.IsLightTheme ? 0 : 1; // DWMWA_USE_IMMERSIVE_DARK_MODE (라이트 테마면 밝게)
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
int round = 2; // DWMWCP_ROUND
DwmSetWindowAttribute(hwnd, 33, ref round, sizeof(int));
}
catch { }
}
private void OnWindowStateChanged(object? sender, EventArgs e)
{
// 최대화 시 모서리 각진(Fluent 표준), 복원 시 둥글게. 캡션 아이콘도 전환.
try
{
var hwnd = new WindowInteropHelper(this).Handle;
int pref = WindowState == WindowState.Maximized ? 0 : 2; // 0=기본(최대화 시 사각)
DwmSetWindowAttribute(hwnd, 33, ref pref, sizeof(int));
if (MaxRestoreBtn != null)
{
MaxRestoreBtn.Content = WindowState == WindowState.Maximized ? "" : "";
MaxRestoreBtn.ToolTip = WindowState == WindowState.Maximized ? "복원" : "최대화";
}
}
catch { }
}
// ---- 보더리스: 툴바의 빈 공간을 드래그 (버튼/입력창 위는 제외해 클릭 전달) ----
private void TitleBar_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
// 더블클릭 → 최대화/복원 (네이티브 타이틀바 동작)
if (e.ClickCount >= 2) { MaximizeToggle(); return; }
// 클릭된 대상(또는 조상)이 버튼/입력창이면 드래그하지 않고 클릭을 그대로 흘려보낸다
for (var d = e.OriginalSource as DependencyObject; d != null; d = LogicalTreeHelper.GetParent(d) ?? VisualTreeHelper.GetParent(d))
{
if (d is ButtonBase || d is TextBox) return;
}
if (e.LeftButton == MouseButtonState.Pressed) { try { DragMove(); } catch { } }
}
private void Minimize_Click(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
private void MaxRestore_Click(object sender, RoutedEventArgs e) => MaximizeToggle();
private void Close_Click(object sender, RoutedEventArgs e) => Close();
private void MaximizeToggle()
=> WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute);
/// <summary>WebView2 영속 프로필 경로. 쿠키·세션·localStorage·캐시가 여기 보존되어 앱/빌드 재시작에도 유지.</summary>
internal static string ProfileFolder => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader", "webview");
private async Task InitializeBrowserAsync()
{
try
{
// 명시적 영속 프로필: 기본(exe 디렉터리) 폴더는 빌드/재배포 시 날아가 로그인이 풀림 → %AppData% 로 고정.
Directory.CreateDirectory(ProfileFolder);
var env = await CoreWebView2Environment.CreateAsync(browserExecutableFolder: null, userDataFolder: ProfileFolder);
var options = env.CreateCoreWebView2ControllerOptions();
if (_config.PrivateMode) options.IsInPrivateModeEnabled = true; // InPrivate: 디스크에 아무것도 저장 안 함
await Browser.EnsureCoreWebView2Async(env, options);
}
catch (Exception ex) { Log($"WebView2 초기화 실패: {ex.Message}"); return; }
var cv = Browser.CoreWebView2;
UpdatePrivateButton();
cv.Settings.AreDevToolsEnabled = true;
cv.WebResourceResponseReceived += OnResponseReceived;
// #4 팝업/새창을 현재 WebView2 로 강제 라우팅
cv.NewWindowRequested += (_, ne) =>
{
try { if (ne.Uri != null) { ne.Handled = true; cv.Navigate(ne.Uri); } }
catch { }
};
// #13 페이지 내 다운로드(a[download])를 저장 폴더로 라우팅
cv.DownloadStarting += (_, de) =>
{
try
{
var folder = Path.GetFullPath(_config.OutputFolder);
Directory.CreateDirectory(folder);
var fn = Path.GetFileName(de.ResultFilePath);
if (string.IsNullOrWhiteSpace(fn)) fn = $"download_{DateTime.Now:yyyyMMdd_HHmmss}";
de.ResultFilePath = MakeUniqueDownloadName(folder, fn);
_manager.LogPanel($"📥 페이지 다운로드 → 저장 폴더: {fn}");
}
catch { }
};
cv.HistoryChanged += (_, _) => UpdateNavState();
cv.SourceChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
{
if (_pendingHistoryUrl != null) RecordHistory(_pendingHistoryUrl, _pendingHistoryUrl);
var url = cv.Source ?? "";
AddressBox.Text = url;
_pendingHistoryUrl = url.StartsWith("about:", StringComparison.OrdinalIgnoreCase) ? null : url;
DetectSocial(url);
}));
cv.DocumentTitleChanged += (_, _) => Dispatcher.BeginInvoke(new Action(() =>
{
_currentTitle = cv.DocumentTitle ?? "";
if (_pendingHistoryUrl != null)
{
RecordHistory(string.IsNullOrWhiteSpace(_currentTitle) ? _pendingHistoryUrl : _currentTitle, _pendingHistoryUrl);
_pendingHistoryUrl = null;
}
// 소셜 카드 제목을 페이지 타이틀로 갱신(출력 파일명 포함, 시작 전일 때)
if (_pendingSocialUrl != null && !string.IsNullOrWhiteSpace(_currentTitle))
{
_manager.RenameSocial(new Uri(_pendingSocialUrl), _currentTitle);
_pendingSocialUrl = null;
}
}));
NavHome_Click(this, null!);
UpdateNavState();
}
private void UpdateNavState()
{
var cv = Browser.CoreWebView2;
if (cv == null) return;
Dispatcher.BeginInvoke(new Action(() =>
{
NavBack.IsEnabled = cv.CanGoBack;
NavForward.IsEnabled = cv.CanGoForward;
}));
}
private void OnResponseReceived(object? sender, CoreWebView2WebResourceResponseReceivedEventArgs e)
{
try
{
var uri = e.Request.Uri ?? "";
var method = e.Request.Method ?? "GET";
string ct = "", cl = "";
int status = 0;
try { ct = e.Response.Headers.GetHeader("Content-Type") ?? ""; } catch { }
try { cl = e.Response.Headers.GetHeader("Content-Length") ?? ""; } catch { }
try { status = e.Response.StatusCode; } catch { }
int n = Interlocked.Increment(ref _totalRequests);
var ctLow = (ct ?? "").ToLowerInvariant();
// #8 노이즈 content-type(image/font/css/js/beacon)은 requests.log 기록 스킵
bool loggable = !ctLow.StartsWith("image/") && !ctLow.StartsWith("font/")
&& !ctLow.Contains("/css") && !ctLow.Contains("javascript")
&& !ctLow.Contains("beacon") && !ctLow.Contains("ping");
if (loggable) _manager.AppendAll($"{n}|{status}|{method}|{ct}|{cl}|{uri}");
if (status >= 300) return;
var l = uri.ToLowerInvariant();
var c = (ct ?? "").ToLowerInvariant();
long len = long.TryParse(cl, out var lv) ? lv : -1;
// 206(Partial Content) Range 응답은 Content-Length 가 청크 크기라 작게 잡힘 →
// Content-Range 의 전체 크기(/total) 로 보정 (Instagram 등 단일 MP4 Range 스트리밍 감지).
try
{
var cr = e.Response.Headers.GetHeader("Content-Range");
if (!string.IsNullOrEmpty(cr))
{
var slash = cr.LastIndexOf('/');
if (slash >= 0 && long.TryParse(cr.AsSpan(slash + 1), out var total) && total > len)
len = total;
}
}
catch { }
string referer = Browser.CoreWebView2?.Source ?? "";
try { var r = e.Request.Headers.GetHeader("Referer"); if (!string.IsNullOrEmpty(r)) referer = r; } catch { }
var kind = MediaDetector.Classify(uri, ct, len, _config);
if (kind == MediaKind.Hls)
{
_manager.LogPanel($"★ HLS 감지: {uri}");
_manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Hls, ct));
}
else if (kind == MediaKind.Dash)
{
_manager.LogPanel($"★ DASH 감지: {uri}");
_manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Dash, ct));
}
else if (kind == MediaKind.Direct)
{
// #15(b) chunked video/* (len<0) 는 크기 가드를 건너뛰어 토큰 만료 HTML 오류 페이지가 mp4 로 오탐됨 →
// magic-byte probe 로 본문이 실제 미디어인지 재검증(정상 chunked MP4 는 ftyp box 로 통과).
if (c.StartsWith("video/") && len < 0 && Uri.TryCreate(uri, UriKind.Absolute, out var probeUri))
ProbeMediaAsync(probeUri, referer);
else
{
_manager.LogPanel($"★ 미디어 감지 [{ct}] {FmtLen(len)}: {uri}");
_manager.OnDetected(new DetectionContext(uri, referer, MediaKind.Direct, ct));
}
}
else
{
// #15 위장 미디어(image/* 또는 octet-stream, 대용량) → magic byte 검증
long minBytes = _config.MinSizeMB * 1024L * 1024L;
bool disguised = (c.StartsWith("image/") || c.Contains("octet-stream")) && (len < 0 || len > minBytes);
if (disguised && Uri.TryCreate(uri, UriKind.Absolute, out var probeUri))
ProbeMediaAsync(probeUri, referer);
else if (MediaDetector.LooksLikeCandidate(l, c))
_manager.LogPanel($"[후보 {method}] {uri}");
}
}
catch { }
}
private async void ProbeMediaAsync(Uri uri, string referer)
{
try
{
string key = uri.GetLeftPart(UriPartial.Path);
lock (_probed)
{
if (_probeCount > 40) return; // 세션당 상한
if (!_probed.Add(key)) return; // 중복 URL 스킵
_probeCount++;
}
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
req.Headers.Range = new RangeHeaderValue(0, 63);
req.Headers.UserAgent.ParseAdd(_config.UserAgent);
if (!string.IsNullOrEmpty(referer)) { try { req.Headers.Referrer = new Uri(referer); } catch { } }
using var resp = await ProbeClient.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
if ((int)resp.StatusCode >= 300) return;
await using var stream = await resp.Content.ReadAsStreamAsync();
var head = new byte[64];
int total = 0, m;
while (total < 64 && (m = await stream.ReadAsync(head, total, 64 - total)) > 0) total += m;
var fmt = MediaDetector.ProbeFormat(head.AsSpan(0, total));
if (fmt != null)
{
_manager.LogPanel($"★ 위장 미디어 감지 (→{fmt}): {uri}");
_manager.OnDetected(new DetectionContext(uri.ToString(), referer, MediaKind.Direct, "video/" + fmt));
}
}
catch { }
}
private static string FmtLen(long len) => len < 0 ? "?" : $"{len / 1048576.0:F1}MB";
private async Task<List<CoreWebView2Cookie>> GetCookiesAsync(Uri url)
{
var cv = Browser.CoreWebView2;
if (cv == null) return new();
try { return (await cv.CookieManager.GetCookiesAsync(url.ToString())).ToList(); }
catch { return new(); }
}
private void Log(string s)
{
var line = $"[{DateTime.Now:HH:mm:ss}] {s}";
try { File.AppendAllText(_appLogPath, line + Environment.NewLine); } catch { }
Dispatcher.BeginInvoke(new Action(() => { StatusText.Text = s; }));
}
// ---- 네비게이션 ----
private void Go_Click(object sender, RoutedEventArgs e) => NavigateTo(AddressBox.Text);
// ---- 주소창 자동완성/제안 ----
private void AddressBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (_suggesting) return;
UpdateSuggestions(AddressBox.Text);
TryInlineSuggest(AddressBox.Text);
}
private void UpdateSuggestions(string text)
{
text = text?.Trim() ?? "";
if (text.Length == 0 || !AddressBox.IsKeyboardFocused)
{
AddressPopup.IsOpen = false;
return;
}
var low = text.ToLowerInvariant();
var matches = _history
.Where(h => h.Url.ToLowerInvariant().Contains(low)
|| (h.Title ?? "").ToLowerInvariant().Contains(low))
.Take(8)
.ToList();
AddressSuggestions.ItemsSource = matches;
AddressSuggestions.SelectedIndex = matches.Count > 0 ? 0 : -1;
AddressPopup.IsOpen = matches.Count > 0;
}
// 인라인 자동완성: 방문한 URL 중 입력이 접두사면 나머지를 선택영역으로 채움 (Tab/→ 로 확정)
private void TryInlineSuggest(string text)
{
if (text.Length == 0 || AddressBox.CaretIndex != text.Length) return;
var low = text.ToLowerInvariant();
var match = _history.FirstOrDefault(h => h.Url.Length > text.Length
&& h.Url.ToLowerInvariant().StartsWith(low));
if (match == null) return;
_suggesting = true;
AddressBox.Text = match.Url;
AddressBox.Select(text.Length, match.Url.Length - text.Length);
_suggesting = false;
}
private void AddressBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
var k = e.Key;
// ↑/↓ 제안 목록 탐색
if (AddressPopup.IsOpen && AddressSuggestions.HasItems && (k == Key.Down || k == Key.Up))
{
int n = AddressSuggestions.Items.Count;
int delta = k == Key.Down ? 1 : -1;
int i = Math.Clamp(AddressSuggestions.SelectedIndex + delta, 0, n - 1);
AddressSuggestions.SelectedIndex = i;
AddressSuggestions.ScrollIntoView(AddressSuggestions.Items[i]);
e.Handled = true;
return;
}
// Tab / → 로 인라인 제안 확정 (선택영역을 끝으로 붕괴)
if ((k == Key.Tab || k == Key.Right) && AddressBox.SelectionLength > 0
&& AddressBox.CaretIndex + AddressBox.SelectionLength == AddressBox.Text.Length)
{
AddressBox.CaretIndex = AddressBox.Text.Length;
AddressBox.Select(AddressBox.Text.Length, 0);
e.Handled = true;
return;
}
if (k == Key.Escape) { AddressPopup.IsOpen = false; e.Handled = true; return; }
if (k == Key.Enter)
{
AddressPopup.IsOpen = false;
if (AddressSuggestions.SelectedItem is HistoryEntry sel)
NavigateTo(sel.Url);
else
NavigateTo(AddressBox.Text);
Keyboard.ClearFocus();
e.Handled = true;
}
}
// 제안 항목 클릭 → 이동
private void AddressSuggestion_Click(object sender, MouseButtonEventArgs e)
{
if (AddressSuggestions.SelectedItem is HistoryEntry sel)
{
AddressPopup.IsOpen = false;
NavigateTo(sel.Url);
}
}
private void NavBack_Click(object sender, RoutedEventArgs e) => Browser.CoreWebView2?.GoBack();
private void NavForward_Click(object sender, RoutedEventArgs e) => Browser.CoreWebView2?.GoForward();
private void NavRefresh_Click(object sender, RoutedEventArgs e) => Browser.CoreWebView2?.Reload();
private void NavHome_Click(object sender, RoutedEventArgs e) => NavigateTo(_config.HomeUrl);
private void NavigateTo(string? url)
{
url = url?.Trim();
if (string.IsNullOrEmpty(url)) return;
if (!url.StartsWith("http", StringComparison.OrdinalIgnoreCase)) url = "https://" + url;
Browser.CoreWebView2?.Navigate(url);
}
// 소셜 영상 페이지(YouTube/Instagram/X/...) 감지 → yt-dlp 카드 등록.
private void DetectSocial(string? url)
{
if (string.IsNullOrEmpty(url)) { _pendingSocialUrl = null; return; }
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) { _pendingSocialUrl = null; return; }
if (SocialSiteDetector.TryMatch(uri, out var normalized, out var label))
{
_manager.LogPanel($"★ 소셜 감지 [{label}]: {normalized}");
_manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label));
_pendingSocialUrl = normalized;
}
else _pendingSocialUrl = null;
}
// ---- 링크로 받기 (URL 붙여넣기) ----
private void ToggleLink(object sender, RoutedEventArgs e)
{
BookmarksPopup.IsOpen = false;
HistoryPopup.IsOpen = false;
LinkPopup.IsOpen = !LinkPopup.IsOpen;
if (LinkPopup.IsOpen) { LinkBox.Clear(); Dispatcher.BeginInvoke(new Action(() => LinkBox.Focus())); }
}
private void LinkBox_PreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter) { LinkDownload_Click(sender, e); e.Handled = true; }
}
private void LinkDownload_Click(object sender, RoutedEventArgs e)
{
var raw = (LinkBox.Text ?? "").Trim();
if (string.IsNullOrEmpty(raw)) return;
if (!raw.StartsWith("http", StringComparison.OrdinalIgnoreCase)) raw = "https://" + raw;
if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri)) { Log("올바른 링크가 아닙니다"); return; }
if (SocialSiteDetector.TryMatch(uri, out var normalized, out var label))
{
_manager.LogPanel($"★ 소셜 감지 [{label}]: {normalized}");
_manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label));
_pendingSocialUrl = normalized;
LinkPopup.IsOpen = false;
Log("카드에 추가했습니다. 화질을 선택하고 받기를 누르세요");
}
else Log("지원하는 소셜 영상 링크가 아닙니다 (YouTube·Instagram·X·TikTok·Facebook)");
}
// #7 전역 키보드 단축키
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
var cv = Browser.CoreWebView2;
var mods = Keyboard.Modifiers;
if (mods == ModifierKeys.Control && e.Key == Key.L) { AddressBox.Focus(); AddressBox.SelectAll(); e.Handled = true; }
else if (e.Key == Key.F5 || (mods == ModifierKeys.Control && e.Key == Key.R)) { cv?.Reload(); e.Handled = true; }
else if (e.Key == Key.Escape) { try { cv?.Stop(); } catch { } e.Handled = true; }
else if (mods == ModifierKeys.Alt && e.Key == Key.Left) { try { if (cv?.CanGoBack == true) cv.GoBack(); } catch { } e.Handled = true; }
else if (mods == ModifierKeys.Alt && e.Key == Key.Right) { try { if (cv?.CanGoForward == true) cv.GoForward(); } catch { } e.Handled = true; }
else if (mods == ModifierKeys.Control)
{
if (e.Key == Key.OemPlus || e.Key == Key.Add) { try { Browser.ZoomFactor = Math.Min(Browser.ZoomFactor + 0.1, 5); } catch { } e.Handled = true; }
else if (e.Key == Key.OemMinus || e.Key == Key.Subtract) { try { Browser.ZoomFactor = Math.Max(Browser.ZoomFactor - 0.1, 0.1); } catch { } e.Handled = true; }
else if (e.Key == Key.D0) { try { Browser.ZoomFactor = 1.0; } catch { } e.Handled = true; }
}
}
private static string MakeUniqueDownloadName(string folder, string file)
{
var p = Path.Combine(folder, file);
if (!File.Exists(p)) return p;
var name = Path.GetFileNameWithoutExtension(file);
var ext = Path.GetExtension(file);
int i = 1;
while (File.Exists(Path.Combine(folder, $"{name} ({i}){ext}"))) i++;
return Path.Combine(folder, $"{name} ({i}){ext}");
}
// ---- 범용 우측 패널: 접기/펼치기 + 리사이즈 + 상태 저장 ----
private static double ClampPanelWidth(double w) => Math.Clamp(w, MinExpandedWidth, MaxExpandedWidth);
private void ApplyPanelState(bool collapsed)
{
_panelCollapsed = collapsed;
RightCol.Width = new GridLength(collapsed ? CollapsedPanelWidth : _panelExpandedWidth);
PanelBody.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible;
PanelTitle.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible;
PanelCountBadge.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible;
RightSplitter.Visibility = collapsed ? Visibility.Collapsed : Visibility.Visible;
// ←(펼치기) / →(접기) chevron. 접힘 시에도 토글 버튼은 헤더에 남아 클릭 가능.
PanelToggle.Content = collapsed ? "" : "";
PanelToggle.ToolTip = collapsed ? "패널 펼치기" : "패널 접기";
}
private void PanelToggle_Click(object sender, RoutedEventArgs e)
{
// 접기 직전 폭을 기억(다시 펼칠 때 복원)
if (!_panelCollapsed) _panelExpandedWidth = ClampPanelWidth(RightCol.Width.Value);
ApplyPanelState(!_panelCollapsed);
PersistPanel();
}
private void RightSplitter_DragCompleted(object sender, DragCompletedEventArgs e)
{
if (_panelCollapsed) return;
double w = RightCol.Width.Value;
if (w <= 120) ApplyPanelState(true); // 너무 좁히면 접기 처리
else _panelExpandedWidth = ClampPanelWidth(w);
PersistPanel();
}
private void PersistPanel()
{
_config.RightPanelCollapsed = _panelCollapsed;
_config.RightPanelWidth = _panelCollapsed ? _panelExpandedWidth : ClampPanelWidth(RightCol.Width.Value);
ConfigStore.Save(_config);
}
private void OnWindowClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{
_config.RightPanelCollapsed = _panelCollapsed;
if (!_panelCollapsed) _config.RightPanelWidth = ClampPanelWidth(RightCol.Width.Value);
ConfigStore.Save(_config);
}
// ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ----
private void UpdatePrivateButton()
{
if (PrivateBtn == null) return;
try
{
PrivateBtn.Foreground = _config.PrivateMode
? (Brush)FindResource("AccentBrush")
: (Brush)FindResource("TextSecondary");
PrivateBtn.ToolTip = _config.PrivateMode
? "개인정보 보호 모드 켜짐 — 클릭 시 일반 모드로 전환(재시작)"
: "개인정보 보호 모드(InPrivate)";
}
catch { }
}
// 토글 → 확인 → 플래그 저장 → 재시작(테마 전환과 동일 패턴)
private void TogglePrivate_Click(object sender, RoutedEventArgs e)
{
bool next = !_config.PrivateMode;
var msg = next
? "개인정보 보호 모드(InPrivate)로 전환합니다.\n이 모드에서는 쿠키·세션·localStorage가 저장되지 않습니다.\n앱이 재시작되며 현재 페이지는 닫힙니다."
: "일반 모드로 전환합니다.\n앱이 재시작되며 현재 페이지는 닫힙니다.";
if (MessageBox.Show(msg + "\n\n계속할까요?", "브라우저 모드", MessageBoxButton.OKCancel, MessageBoxImage.Question) != MessageBoxResult.OK)
return;
_config.PrivateMode = next;
ConfigStore.Save(_config);
RestartApp();
}
/// <summary>현재 프로필의 브라우저 데이터(쿠키·DOM 저장소·캐시) 삭제. 일반 모드: 영속 프로필 정리.</summary>
public async Task ClearBrowsingDataAsync()
{
var cv = Browser.CoreWebView2;
if (cv == null) { Log("브라우저가 아직 준비되지 않았습니다"); return; }
try
{
await cv.Profile.ClearBrowsingDataAsync();
Log("브라우저 데이터(쿠키·localStorage·캐시)를 삭제했습니다. 페이지를 새로고침하세요.");
}
catch (Exception ex) { Log($"데이터 삭제 실패: {ex.Message}"); }
}
// ---- 즐겨찾기 ----
private void ToggleBookmarks(object sender, RoutedEventArgs e)
{
HistoryPopup.IsOpen = false;
LinkPopup.IsOpen = false;
BookmarksPopup.IsOpen = !BookmarksPopup.IsOpen;
}
private void AddBookmark_Click(object sender, RoutedEventArgs e)
{
var cv = Browser.CoreWebView2;
var url = cv?.Source ?? "";
if (string.IsNullOrEmpty(url) || url.StartsWith("about:", StringComparison.OrdinalIgnoreCase)) return;
if (_bookmarks.Any(b => b.Url == url)) { Log("이미 즐겨찾기에 있습니다"); return; }
var title = Trunc(string.IsNullOrWhiteSpace(cv!.DocumentTitle) ? url : cv.DocumentTitle, 80);
_bookmarks.Insert(0, new Bookmark(title, url));
BrowserStore.SaveBookmarks(_bookmarks.ToList());
Log($"즐겨찾기 추가: {title}");
}
private void RemoveBookmark_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement fe && fe.DataContext is Bookmark b)
{
_bookmarks.Remove(b);
BrowserStore.SaveBookmarks(_bookmarks.ToList());
}
}
private void OpenBookmark_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement fe && fe.DataContext is Bookmark b)
{
BookmarksPopup.IsOpen = false;
NavigateTo(b.Url);
}
}
// ---- 히스토리 ----
private void ToggleHistory(object sender, RoutedEventArgs e)
{
BookmarksPopup.IsOpen = false;
LinkPopup.IsOpen = false;
HistoryPopup.IsOpen = !HistoryPopup.IsOpen;
}
private void OpenHistory_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement fe && fe.DataContext is HistoryEntry h)
{
HistoryPopup.IsOpen = false;
NavigateTo(h.Url);
}
}
private void ClearHistory_Click(object sender, RoutedEventArgs e)
{
_history.Clear();
BrowserStore.SaveHistory(new List<HistoryEntry>());
Log("방문 기록을 삭제했습니다");
}
private void RecordHistory(string title, string url)
{
if (string.IsNullOrEmpty(url)) return;
if (_history.Count > 0 && _history[0].Url == url) return;
_history.Insert(0, new HistoryEntry(Trunc(title, 80), url, DateTime.Now));
while (_history.Count > 500) _history.RemoveAt(_history.Count - 1);
BrowserStore.SaveHistory(_history.ToList());
}
// ---- 카드 액션 ----
private void CardDownload_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it)
_ = _manager.StartDownload(it);
}
// 소셜 카드: yt-dlp -J 로 사용 가능한 화질 조회 → 드롭다운 교체
private void CardFetchFormats_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it)
_ = _manager.FetchFormatsAsync(it);
}
private void CardRetry_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it)
_ = _manager.RetryDownload(it);
}
// #1 다운로드 취소
private void CardCancel_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it)
{
_manager.Cancel(it);
Log($"취소 요청: {it.Title}");
}
}
private void CardFolder_Click(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement fe && fe.DataContext is DownloadItemViewModel it)
OpenInExplorer(it.OutputPath);
}
private void OpenFolder_Click(object sender, RoutedEventArgs e) => OpenInExplorer(null);
// #3 explorer 인자 인젝션 방지: 경로 정규화 + 존재 검증 + ArgumentList/UseShellExecute
private void OpenInExplorer(string? selectFile)
{
try
{
var folder = Path.GetFullPath(_config.OutputFolder);
if (!Directory.Exists(folder)) return;
if (!string.IsNullOrEmpty(selectFile) && File.Exists(selectFile))
{
var psi = new System.Diagnostics.ProcessStartInfo("explorer.exe") { UseShellExecute = false };
psi.ArgumentList.Add($"/select,{selectFile}");
System.Diagnostics.Process.Start(psi);
}
else
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = folder,
UseShellExecute = true,
});
}
}
catch { }
}
private void Settings_Click(object sender, RoutedEventArgs e)
{
var w = new SettingsWindow(_config, ClearBrowsingDataAsync) { Owner = this };
if (w.ShowDialog() == true)
{
_config = w.Config;
ConfigStore.Save(_config);
_manager.UpdateMaxConcurrent(_config.MaxConcurrentDownloads); // #19 런타임 반영
if (w.ThemeChanged) RestartApp();
}
}
// 테마 변경 등은 리소스가 StaticResource 로 물려있어 런타임 전환이 어려움 → 재시작으로 적용.
private static void RestartApp()
{
var exe = Environment.ProcessPath;
if (!string.IsNullOrEmpty(exe))
System.Diagnostics.Process.Start(exe);
Application.Current.Shutdown();
}
private static string Trunc(string s, int n) => string.IsNullOrEmpty(s) ? "" : (s.Length <= n ? s : s[..n] + "…");
}

View file

@ -0,0 +1,63 @@
# Video Downloader (WPF 데스크톱 앱)
임베디드 브라우저(WebView2)로 영상을 재생하면, 네트워크 요청을 자동으로 관찰해
`.m3u8`(HLS)를 감지하고 **즉시 MP4로 다운로드**하는 Windows 데스크톱 앱.
> 브라우저에서 재생만 하면 됩니다 — m3u8 주소를 직접 찾을 필요가 없습니다.
## 구조
- `VideoDownloader.Core` (net8.0) — HLS 다운로더 엔진 (파서/AES-128/fMP4/병렬다운로드/ffmpeg 리먹스). UI/브라우저 의존성 없음.
- `VideoDownloader.Cli` (net8.0) — 엔진 단독 테스트 하네스 (m3u8 URL 직접 입력 → MP4).
- `VideoDownloader.App` (net8.0-windows, WPF + WebView2) — 데스크톱 앱 본체.
## 요구사항
- **.NET 8 SDK** 이상 (Windows)
- **WebView2 런타임** — Windows 11 은 기본 설치됨 (Win10 은 Microsoft Edge 기반 자동 설치)
- **ffmpeg** — 시스템 PATH 에 있으면 자동 사용 (TS/fMP4 → MP4 리먹스). 없으면 설정에서 경로 지정.
## 빌드 및 실행
```bash
# 빌드 (x64 필수 — net8.0-windows WPF)
dotnet build VideoDownloader.App/VideoDownloader.App.csproj -c Debug -p:Platform=x64
# 실행
VideoDownloader.App\bin\x64\Debug\net8.0-windows\VideoDownloader.exe
# 또는
dotnet run --project VideoDownloader.App -p:Platform=x64
```
배포용 단일 디렉토리 게시:
```bash
dotnet publish VideoDownloader.App/VideoDownloader.App.csproj -c Release -p:Platform=x64 -r win-x64 --self-contained false -o publish
```
## 사용법
1. 앱 실행 → 주소창에 재생페이지 URL 입력 후 **Enter** (또는 **이동**)
2. 페이지에서 영상 **재생**
3. 영상이 재생되는 순간 앱이 `.m3u8` 요청을 자동 감지 → 하단 **다운로드 목록**에 진행률 표시
4. 완료되면 `%USERPROFILE%\Videos\VideoDownloader\` 에 MP4 저장
- 파일명은 페이지 제목(document.title)에서 자동 생성
- 같은 영상은 자동으로 중복 다운로드 방지
### 설정 (상단 **설정** 버튼)
- 저장 폴더 (기본: `%USERPROFILE%\Videos\VideoDownloader`)
- 동시 다운로드 연결 수 (기본 8)
- 자동 다운로드 토글 (기본 켜짐 — 감지 즉시 받기)
- ffmpeg 경로 (비워두면 PATH 사용)
## 작동 원리
1. **탐지**: WebView2 의 `WebResourceRequested` + `All` 리소스 필터로 모든 네트워크 요청 관찰 → URL 경로에 `.m3u8` 이면 감지
2. **해석**: 감지된 m3u8 을 재요청(브라우저 쿠키 + Referer 복사) → 마스터 재생목록이면 **최고 화질** 변환 선택 → 미디어 재생목록 확정 (최종 URL 기준 중복제거)
3. **다운로드**: 세그먼트 병렬 다운로드(이어백기 지원) + AES-128 복호화 + fMP4 처리 → 병합 → ffmpeg `-c copy` 무손실 리먹스 → MP4
## 문제 해결
- **영상이 감지되지 않음** — 사이트가 DRM(Widevine)을 쓰거나 비-HLS(mp4 직링크/재생자체 난독화)일 수 있음. DRM 영상은 다운로드 불가.
- **403/401 오류** — 쿠키/Referer 자동 복사가 안 된 경우. 페이지에서 실제로 재생을 시작했는지 확인 (재생해야 토큰 URL이 발행됨).
- **MP4가 안 만들어짐** — ffmpeg 확인 (`ffmpeg -version`). 설정에서 경로 지정.
- **라이브 방송** — VOD(EXT-X-ENDLIST) 우선 지원. 슬라이딩 윈도우 라이브는 v1 미지원.
- 엔진 단독 점검: `dotnet run --project VideoDownloader.Cli -- "<m3u8-url>"`
## 범위 외
- DRM/Widevine 암호화 영상 (복호화 불가)
- 라이브 스트림(sliding window)
- 오디오/자막 트랙 분리

View file

@ -0,0 +1,167 @@
<Window x:Class="VideoDownloader.App.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="설정" Height="600" Width="620"
FontFamily="{StaticResource UiFont}"
Background="{StaticResource WindowBgGradient}"
WindowStartupLocation="CenterOwner" ResizeMode="CanResize" ShowInTaskbar="False"
WindowStyle="None">
<WindowChrome.WindowChrome>
<WindowChrome CaptionHeight="0"
ResizeBorderThickness="6"
GlassFrameThickness="1"
CornerRadius="0"
UseAeroCaptionButtons="False"
NonClientFrameEdges="None"/>
</WindowChrome.WindowChrome>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- 캡션 (드래그 핸들 + 닫기) -->
<Border Grid.Row="0" Background="{StaticResource PanelBg}"
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1"
Padding="22,11,0,11" MouseLeftButtonDown="TitleBar_MouseDown">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource CloseButton}"
Content="&#xE8BB;" ToolTip="닫기" Click="Close_Click"/>
<TextBlock Text="설정" Foreground="{StaticResource TextPrimary}"
FontWeight="SemiBold" FontSize="14" VerticalAlignment="Center"/>
</DockPanel>
</Border>
<ScrollViewer Grid.Row="1" VerticalScrollBarVisibility="Auto" Padding="22,18">
<StackPanel>
<TextBlock Text="외관" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<CheckBox Name="ThemeLightBox" Style="{StaticResource DarkCheckBox}"
Content="라이트 테마 사용 (적용 시 앱 재시작)" Margin="0,8,0,18"/>
<TextBlock Text="저장 폴더" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,18">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,4,4" Click="BrowseFolder"/>
<TextBox Name="FolderBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock Text="홈 페이지 (홈 버튼이 이동할 주소)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,18">
<TextBox Name="HomeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
<Grid Margin="0,0,0,18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="세그먼트 동시 수" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,0">
<TextBox Name="ConcurrencyBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="최대 동시 항목" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,0">
<TextBox Name="MaxConcurrentBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Text="최소 크기 (MB)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,0">
<TextBox Name="MinSizeBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
</StackPanel>
</Grid>
<TextBlock Text="ffmpeg 경로" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,4">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="8,4,4,4" Click="BrowseFfmpeg"/>
<TextBox Name="FfmpegBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock Text="비워두면 시스템 PATH 의 ffmpeg 사용 (HLS→MP4 리먹스)" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,0,0,18"/>
<TextBlock Text="소셜 영상 (YouTube·Instagram·X)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<TextBlock Text="yt-dlp 로 소셜 사이트 영상을 받습니다" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,2,0,8"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,4">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="재설치" Padding="12,4" Margin="4,4,8,4" ToolTip="yt-dlp 최신 버전으로 다시 다운로드" Click="ReinstallYtDlp_Click"/>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="..." Padding="12,4" Margin="4,4,4,4" Click="BrowseYtDlp"/>
<TextBox Name="YtDlpBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</DockPanel>
</Border>
<TextBlock Text="비워두면 자동/PATH 의 yt-dlp 사용" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,0,0,10"/>
<WrapPanel Orientation="Horizontal" Margin="0,0,0,14">
<CheckBox Name="UseCookiesBox" Style="{StaticResource DarkCheckBox}" Content="로그인 필요 영상을 위해 브라우저 쿠키 사용" Margin="4,4"/>
<CheckBox Name="AutoInstallBox" Style="{StaticResource DarkCheckBox}" Content="yt-dlp 가 없으면 자동 다운로드" Margin="4,4"/>
<CheckBox Name="AutoUpdateBox" Style="{StaticResource DarkCheckBox}" Content="YouTube 차단 시 yt-dlp 자동 갱신 후 재시도" Margin="4,4"/>
<CheckBox Name="AutoInstallDenoBox" Style="{StaticResource DarkCheckBox}" Content="Deno(JS 런타임) 자동 설치 — YouTube 차단 해제용" Margin="4,4"/>
</WrapPanel>
<StackPanel Margin="0,0,0,18">
<TextBlock Text="기본 화질" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<ComboBox Name="DefaultQualityBox" Style="{StaticResource DarkComboBox}" SelectedValuePath="Tag" Margin="0,6,0,0">
<ComboBoxItem Content="최고 화질 (MP4)" Tag="best"/>
<ComboBoxItem Content="1080p" Tag="1080"/>
<ComboBoxItem Content="720p" Tag="720"/>
<ComboBoxItem Content="480p" Tag="480"/>
<ComboBoxItem Content="360p" Tag="360"/>
<ComboBoxItem Content="오디오만 (MP3)" Tag="audio"/>
</ComboBox>
</StackPanel>
<StackPanel Margin="0,0,0,18">
<TextBlock Text="YouTube 클라이언트 (고급)" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,6,0,0">
<TextBox Name="YoutubeClientsBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent"/>
</Border>
<TextBlock Text="비워두면 yt-dlp 기본값 사용(권장). 봇 차단/PO 토큰 오류 시 예: tv,android_vr,web" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,4,0,0" TextWrapping="Wrap"/>
</StackPanel>
<TextBlock Text="브라우저 데이터" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold" Margin="0,0,0,2"/>
<TextBlock x:Name="ProfileModeText" Text="" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,0,0,8" TextWrapping="Wrap"/>
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="9" Margin="0,0,0,18">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource GhostButton}" Content="삭제" Padding="12,4" Margin="8,4,4,4"
ToolTip="쿠키·localStorage·캐시 삭제 (현재 프로필)" Click="ClearData_Click"/>
<TextBox x:Name="ProfilePathBox" Style="{StaticResource DarkTextBox}" BorderThickness="0" Background="Transparent" IsReadOnly="True"/>
</DockPanel>
</Border>
<TextBlock Text="반응할 형식" Foreground="{StaticResource TextPrimary}" FontWeight="SemiBold"/>
<TextBlock Text="체크된 형식만 자동 감지/다운로드합니다" FontSize="11" Foreground="{StaticResource TextMuted}" Margin="0,2,0,10"/>
<WrapPanel Orientation="Horizontal">
<CheckBox Name="FmtHls" Style="{StaticResource DarkCheckBox}" Content="HLS (m3u8)" Margin="4,5"/>
<CheckBox Name="FmtDash" Style="{StaticResource DarkCheckBox}" Content="DASH (.mpd)" Margin="4,5"/>
<CheckBox Name="FmtMp4" Style="{StaticResource DarkCheckBox}" Content="MP4" Margin="4,5"/>
<CheckBox Name="FmtWebM" Style="{StaticResource DarkCheckBox}" Content="WebM" Margin="4,5"/>
<CheckBox Name="FmtMkv" Style="{StaticResource DarkCheckBox}" Content="MKV" Margin="4,5"/>
<CheckBox Name="FmtM4v" Style="{StaticResource DarkCheckBox}" Content="M4V" Margin="4,5"/>
<CheckBox Name="FmtMov" Style="{StaticResource DarkCheckBox}" Content="MOV" Margin="4,5"/>
<CheckBox Name="FmtAvi" Style="{StaticResource DarkCheckBox}" Content="AVI" Margin="4,5"/>
<CheckBox Name="FmtAudio" Style="{StaticResource DarkCheckBox}" Content="오디오 (m4a/mp3/ogg...)" Margin="4,5"/>
</WrapPanel>
</StackPanel>
</ScrollViewer>
<Border Grid.Row="2" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0" Padding="22,12">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Style="{StaticResource GhostButton}" Content="취소" Width="90" Margin="0,0,8,0" IsCancel="True"/>
<Button Style="{StaticResource PrimaryButton}" Content="확인" Width="90" Click="Ok_Click" IsDefault="True"/>
</StackPanel>
</Border>
</Grid>
</Window>

View file

@ -0,0 +1,165 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Interop;
using Microsoft.Win32;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Social;
namespace VideoDownloader.App;
public partial class SettingsWindow : Window
{
public AppConfig Config { get; }
private readonly string _origTheme;
private readonly Func<Task>? _clearBrowsingData;
public bool ThemeChanged { get; private set; }
public SettingsWindow(AppConfig config, Func<Task>? clearBrowsingData = null)
{
InitializeComponent();
Config = config;
_origTheme = config.Theme;
_clearBrowsingData = clearBrowsingData;
FolderBox.Text = config.OutputFolder;
HomeBox.Text = config.HomeUrl;
ConcurrencyBox.Text = config.Concurrency.ToString();
MinSizeBox.Text = config.MinSizeMB.ToString();
MaxConcurrentBox.Text = config.MaxConcurrentDownloads.ToString();
ThemeLightBox.IsChecked = config.Theme.Equals("light", StringComparison.OrdinalIgnoreCase);
FfmpegBox.Text = config.FfmpegPath ?? "";
FmtHls.IsChecked = config.EnabledFormats.Contains("hls");
FmtDash.IsChecked = config.EnabledFormats.Contains("dash");
FmtMp4.IsChecked = config.EnabledFormats.Contains("mp4");
FmtWebM.IsChecked = config.EnabledFormats.Contains("webm");
FmtMkv.IsChecked = config.EnabledFormats.Contains("mkv");
FmtM4v.IsChecked = config.EnabledFormats.Contains("m4v");
FmtMov.IsChecked = config.EnabledFormats.Contains("mov");
FmtAvi.IsChecked = config.EnabledFormats.Contains("avi");
FmtAudio.IsChecked = config.EnabledFormats.Contains("audio");
YtDlpBox.Text = config.YtDlpPath ?? "";
UseCookiesBox.IsChecked = config.UseCookiesForSocial;
AutoInstallBox.IsChecked = config.AutoInstallYtDlp;
AutoUpdateBox.IsChecked = config.AutoUpdateYtDlp;
AutoInstallDenoBox.IsChecked = config.AutoInstallDeno;
DefaultQualityBox.SelectedValue = config.DefaultSocialQuality;
if (DefaultQualityBox.SelectedIndex < 0) DefaultQualityBox.SelectedIndex = 0;
YoutubeClientsBox.Text = config.YoutubePlayerClients ?? "";
ProfilePathBox.Text = MainWindow.ProfileFolder;
ProfileModeText.Text = config.PrivateMode
? "개인정보 보호 모드(InPrivate) — 데이터를 저장하지 않습니다"
: "일반 모드 — 쿠키·세션·localStorage 를 프로필에 보존합니다";
}
private void BrowseFolder(object sender, RoutedEventArgs e)
{
var dlg = new OpenFolderDialog();
if (Directory.Exists(FolderBox.Text)) dlg.InitialDirectory = FolderBox.Text;
if (dlg.ShowDialog() == true)
FolderBox.Text = dlg.FolderName;
}
private void BrowseFfmpeg(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog { Filter = "ffmpeg|ffmpeg.exe|실행 파일|*.exe|모든 파일|*.*" };
if (dlg.ShowDialog() == true)
FfmpegBox.Text = dlg.FileName;
}
private void BrowseYtDlp(object sender, RoutedEventArgs e)
{
var dlg = new OpenFileDialog { Filter = "yt-dlp|yt-dlp.exe|실행 파일|*.exe|모든 파일|*.*" };
if (dlg.ShowDialog() == true)
YtDlpBox.Text = dlg.FileName;
}
private async void ReinstallYtDlp_Click(object sender, RoutedEventArgs e)
{
var btn = sender as Button;
if (btn != null) btn.IsEnabled = false;
try
{
var path = await YtDlpRunner.DownloadAsync(Config, null, CancellationToken.None);
MessageBox.Show(path != null ? $"yt-dlp 설치 완료:\n{path}" : "yt-dlp 다운로드에 실패했습니다. 네트워크를 확인하세요.",
"yt-dlp", MessageBoxButton.OK, path != null ? MessageBoxImage.Information : MessageBoxImage.Warning);
if (path != null) YtDlpBox.Text = "";
}
finally { if (btn != null) btn.IsEnabled = true; }
}
private async void ClearData_Click(object sender, RoutedEventArgs e)
{
if (_clearBrowsingData == null) return;
if (MessageBox.Show("쿠키·localStorage·캐시 등 브라우저 데이터를 삭제합니다.\n로그인이 풀릴 수 있습니다. 계속할까요?",
"데이터 삭제", MessageBoxButton.OKCancel, MessageBoxImage.Warning) != MessageBoxResult.OK) return;
var btn = sender as Button;
if (btn != null) btn.IsEnabled = false;
try { await _clearBrowsingData(); }
finally { if (btn != null) btn.IsEnabled = true; }
}
private void Ok_Click(object sender, RoutedEventArgs e)
{
Config.OutputFolder = FolderBox.Text?.Trim() ?? "";
Config.HomeUrl = string.IsNullOrWhiteSpace(HomeBox.Text) ? "https://www.google.com" : HomeBox.Text.Trim();
if (int.TryParse(ConcurrencyBox.Text, out var c)) Config.Concurrency = Math.Max(1, c);
if (long.TryParse(MinSizeBox.Text, out var m) && m >= 0) Config.MinSizeMB = m;
Config.FfmpegPath = string.IsNullOrWhiteSpace(FfmpegBox.Text) ? null : FfmpegBox.Text.Trim();
Config.Theme = ThemeLightBox.IsChecked == true ? "light" : "dark";
if (int.TryParse(MaxConcurrentBox.Text, out var mc)) Config.MaxConcurrentDownloads = Math.Max(1, mc);
ThemeChanged = !Config.Theme.Equals(_origTheme, StringComparison.OrdinalIgnoreCase);
var fmts = new List<string>();
if (FmtHls.IsChecked == true) fmts.Add("hls");
if (FmtDash.IsChecked == true) fmts.Add("dash");
if (FmtMp4.IsChecked == true) fmts.Add("mp4");
if (FmtWebM.IsChecked == true) fmts.Add("webm");
if (FmtMkv.IsChecked == true) fmts.Add("mkv");
if (FmtM4v.IsChecked == true) fmts.Add("m4v");
if (FmtMov.IsChecked == true) fmts.Add("mov");
if (FmtAvi.IsChecked == true) fmts.Add("avi");
if (FmtAudio.IsChecked == true) fmts.Add("audio");
Config.EnabledFormats = fmts;
Config.YtDlpPath = string.IsNullOrWhiteSpace(YtDlpBox.Text) ? null : YtDlpBox.Text.Trim();
Config.UseCookiesForSocial = UseCookiesBox.IsChecked == true;
Config.AutoInstallYtDlp = AutoInstallBox.IsChecked == true;
Config.AutoUpdateYtDlp = AutoUpdateBox.IsChecked == true;
Config.AutoInstallDeno = AutoInstallDenoBox.IsChecked == true;
Config.DefaultSocialQuality = (DefaultQualityBox.SelectedValue as string) ?? "best";
Config.YoutubePlayerClients = string.IsNullOrWhiteSpace(YoutubeClientsBox.Text) ? null : YoutubeClientsBox.Text.Trim();
DialogResult = true;
}
// 보더리스: Win11 어두운 테두리 + 둥근 모서리
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
try
{
var hwnd = new WindowInteropHelper(this).Handle;
int dark = App.IsLightTheme ? 0 : 1; // DWMWA_USE_IMMERSIVE_DARK_MODE (라이트 테마면 밝게)
DwmSetWindowAttribute(hwnd, 20, ref dark, sizeof(int));
int round = 2; // DWMWCP_ROUND
DwmSetWindowAttribute(hwnd, 33, ref round, sizeof(int));
}
catch { }
}
private void TitleBar_MouseDown(object sender, MouseButtonEventArgs e)
{
if (e.LeftButton == MouseButtonState.Pressed) { try { DragMove(); } catch { } }
}
private void Close_Click(object sender, RoutedEventArgs e) => Close();
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attribute, ref int pvAttribute, int cbAttribute);
}

View file

@ -0,0 +1,35 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ===== 다크 팔레트 (단일 보라 액센트 - 의도적 통일) ===== -->
<SolidColorBrush x:Key="WindowBg" Color="#0B0B10"/>
<LinearGradientBrush x:Key="WindowBgGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#11111B" Offset="0"/>
<GradientStop Color="#0B0B10" Offset="0.45"/>
<GradientStop Color="#09090D" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="PanelBg" Color="#14141C"/>
<SolidColorBrush x:Key="GlassBg" Color="#1A1A24"/>
<SolidColorBrush x:Key="CardBg" Color="#1C1C26"/>
<SolidColorBrush x:Key="CardBgHover" Color="#242430"/>
<SolidColorBrush x:Key="BorderBrush" Color="#2C2C3A"/>
<SolidColorBrush x:Key="BorderSubtle" Color="#20202B"/>
<SolidColorBrush x:Key="TextPrimary" Color="#F2F2F6"/>
<SolidColorBrush x:Key="TextSecondary" Color="#9B9BAC"/>
<SolidColorBrush x:Key="TextMuted" Color="#62626E"/>
<SolidColorBrush x:Key="TrackBrush" Color="#262631"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#34D399"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#F87171"/>
<SolidColorBrush x:Key="PendingBrush" Color="#FBBF24"/>
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#6366F1" Offset="0"/>
<GradientStop Color="#A855F7" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="AccentBrush" Color="#7C6CFF"/>
<LinearGradientBrush x:Key="AccentFadeBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#556366F1" Offset="0"/>
<GradientStop Color="#006366F1" Offset="1"/>
</LinearGradientBrush>
</ResourceDictionary>

View file

@ -0,0 +1,35 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- ===== 라이트 팔레트 (동일 키 - Shared.xaml 의 스타일이 그대로 동작) ===== -->
<SolidColorBrush x:Key="WindowBg" Color="#F6F7F9"/>
<LinearGradientBrush x:Key="WindowBgGradient" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#FFFFFF" Offset="0"/>
<GradientStop Color="#F6F7F9" Offset="0.5"/>
<GradientStop Color="#EEF0F3" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="PanelBg" Color="#F0F1F5"/>
<SolidColorBrush x:Key="GlassBg" Color="#E9EBF0"/>
<SolidColorBrush x:Key="CardBg" Color="#FFFFFF"/>
<SolidColorBrush x:Key="CardBgHover" Color="#EEF0F4"/>
<SolidColorBrush x:Key="BorderBrush" Color="#D5D9E1"/>
<SolidColorBrush x:Key="BorderSubtle" Color="#E3E6EC"/>
<SolidColorBrush x:Key="TextPrimary" Color="#1B1C24"/>
<SolidColorBrush x:Key="TextSecondary" Color="#565A6A"/>
<SolidColorBrush x:Key="TextMuted" Color="#8A8E9C"/>
<SolidColorBrush x:Key="TrackBrush" Color="#DBDFE6"/>
<SolidColorBrush x:Key="SuccessBrush" Color="#15A34A"/>
<SolidColorBrush x:Key="ErrorBrush" Color="#DC2626"/>
<SolidColorBrush x:Key="PendingBrush" Color="#C77708"/>
<LinearGradientBrush x:Key="AccentGradient" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#6366F1" Offset="0"/>
<GradientStop Color="#A855F7" Offset="1"/>
</LinearGradientBrush>
<SolidColorBrush x:Key="AccentBrush" Color="#6366F1"/>
<LinearGradientBrush x:Key="AccentFadeBrush" StartPoint="0,0" EndPoint="0,1">
<GradientStop Color="#306366F1" Offset="0"/>
<GradientStop Color="#006366F1" Offset="1"/>
</LinearGradientBrush>
</ResourceDictionary>

View file

@ -0,0 +1,418 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- 폰트 (테마 무관) -->
<FontFamily x:Key="UiFont">Segoe UI Variable Text, Segoe UI</FontFamily>
<FontFamily x:Key="IconFont">Segoe Fluent Icons</FontFamily>
<!-- ===== Primary 버튼 ===== -->
<Style x:Key="PrimaryButton" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Foreground" Value="White"/>
<Setter Property="FontWeight" Value="SemiBold"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="16,8"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="RenderTransformOrigin" Value="0.5,0.5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{StaticResource AccentGradient}" CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Opacity" Value="0.9"/></Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="bd" Property="RenderTransform"><Setter.Value><ScaleTransform ScaleX="0.97" ScaleY="0.97"/></Setter.Value></Setter>
</Trigger>
<Trigger Property="IsEnabled" Value="False"><Setter TargetName="bd" Property="Opacity" Value="0.35"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== Ghost 버튼 ===== -->
<Style x:Key="GhostButton" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondary}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Padding" Value="12,7"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="RenderTransformOrigin" Value="0.5,0.5"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{StaticResource CardBgHover}"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="bd" Property="RenderTransform"><Setter.Value><ScaleTransform ScaleX="0.97" ScaleY="0.97"/></Setter.Value></Setter>
</Trigger>
<Trigger Property="IsEnabled" Value="False"><Setter TargetName="bd" Property="Opacity" Value="0.4"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 아이콘 전용 버튼 (Fluent) ===== -->
<Style x:Key="IconButton" TargetType="Button" BasedOn="{StaticResource GhostButton}">
<Setter Property="FontFamily" Value="{StaticResource IconFont}"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Padding" Value="10,0"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondary}"/>
</Style>
<!-- ===== Fluent 아이콘 TextBlock ===== -->
<Style x:Key="FluentIcon" TargetType="TextBlock">
<Setter Property="FontFamily" Value="{StaticResource IconFont}"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- ===== 윈도우 캡션 버튼 (최소화/최대화/복원) — 고정폭, 서로 밀착(네이티브 Fluent) ===== -->
<Style x:Key="CaptionButton" TargetType="Button" BasedOn="{StaticResource GhostButton}">
<Setter Property="FontFamily" Value="{StaticResource IconFont}"/>
<Setter Property="FontSize" Value="11"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="Width" Value="46"/>
<Setter Property="Height" Value="32"/>
<Setter Property="Foreground" Value="{StaticResource TextSecondary}"/>
<Setter Property="Cursor" Value="Arrow"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="Transparent">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="{StaticResource CardBgHover}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="bd" Property="Background" Value="{StaticResource BorderBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 닫기 버튼 — 빨간 호버 (Fluent 표준) ===== -->
<Style x:Key="CloseButton" TargetType="Button" BasedOn="{StaticResource CaptionButton}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="Transparent">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="#E81123"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 작은 카드 액션 버튼 ===== -->
<Style x:Key="CardActionButton" TargetType="Button" BasedOn="{StaticResource GhostButton}">
<Setter Property="Padding" Value="10,5"/>
<Setter Property="FontSize" Value="11"/>
</Style>
<!-- ===== 투명 행 버튼 ===== -->
<Style x:Key="FlatButton" TargetType="Button">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Padding" Value="10,7"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="Transparent" CornerRadius="7" Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}" VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{StaticResource CardBgHover}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 주소창 자동완성 제안 목록 ===== -->
<Style x:Key="SuggestList" TargetType="ListBox">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="0"/>
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility" Value="Disabled"/>
<Setter Property="MaxHeight" Value="360"/>
</Style>
<Style x:Key="SuggestItem" TargetType="ListBoxItem">
<Setter Property="Padding" Value="12,7"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bd" Background="Transparent" CornerRadius="7" Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="bd" Property="Background" Value="{StaticResource CardBgHover}"/></Trigger>
<Trigger Property="IsSelected" Value="True"><Setter TargetName="bd" Property="Background" Value="{StaticResource GlassBg}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== TextBox ===== -->
<Style x:Key="DarkTextBox" TargetType="TextBox">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Background" Value="{StaticResource GlassBg}"/>
<Setter Property="CaretBrush" Value="{StaticResource TextPrimary}"/>
<Setter Property="SelectionBrush" Value="{StaticResource AccentBrush}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,8"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="TextBox">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="1" CornerRadius="8">
<ScrollViewer x:Name="PART_ContentHost" Margin="{TemplateBinding Padding}"
Focusable="False" HorizontalScrollBarVisibility="Hidden" VerticalScrollBarVisibility="Hidden"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsKeyboardFocused" Value="True"><Setter TargetName="bd" Property="BorderBrush" Value="{StaticResource AccentBrush}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== ProgressBar ===== -->
<Style x:Key="AccentProgressBar" TargetType="ProgressBar">
<Setter Property="Height" Value="8"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ProgressBar">
<Grid>
<!-- PART_Track 이 있어야 ProgressBar 가 PART_Indicator 폭을(Value/Maximum 비율) 계산한다.
없으면 바가 절대 채워지지 않는 치명 결함이었음. -->
<Border x:Name="PART_Track" CornerRadius="4" Background="{StaticResource TrackBrush}"/>
<Border x:Name="PART_Indicator" CornerRadius="4" HorizontalAlignment="Left">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#6366F1" Offset="0"/>
<GradientStop Color="#A855F7" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
</Border>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 부정확(길이 미상) 진행률 바 ===== -->
<Style x:Key="IndeterminateBar" TargetType="ProgressBar">
<Setter Property="Height" Value="8"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ProgressBar">
<Grid ClipToBounds="True">
<Border CornerRadius="4" Background="{StaticResource TrackBrush}"/>
<Border x:Name="shimmer" Width="48" CornerRadius="4" HorizontalAlignment="Left" RenderTransformOrigin="0,0">
<Border.Background>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,0">
<GradientStop Color="#556366F1" Offset="0"/>
<GradientStop Color="#CCA855F7" Offset="0.5"/>
<GradientStop Color="#556366F1" Offset="1"/>
</LinearGradientBrush>
</Border.Background>
<Border.RenderTransform><TranslateTransform X="-50"/></Border.RenderTransform>
</Border>
</Grid>
<ControlTemplate.Triggers>
<EventTrigger RoutedEvent="FrameworkElement.Loaded">
<BeginStoryboard>
<Storyboard>
<DoubleAnimation Storyboard.TargetName="shimmer"
Storyboard.TargetProperty="(UIElement.RenderTransform).(TranslateTransform.X)"
From="-50" To="470" Duration="0:0:1.3" RepeatBehavior="Forever"/>
</Storyboard>
</BeginStoryboard>
</EventTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== CheckBox ===== -->
<Style x:Key="DarkCheckBox" TargetType="CheckBox">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="FontSize" Value="13"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<StackPanel Orientation="Horizontal" Background="Transparent">
<Border x:Name="box" Width="18" Height="18" CornerRadius="5" Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}" BorderThickness="1.5">
<Path x:Name="check" Width="10" Height="10" Stretch="Uniform" Data="M 0 5 L 4 9 L 10 0" Stroke="White" StrokeThickness="2" HorizontalAlignment="Center" VerticalAlignment="Center" Visibility="Collapsed"/>
</Border>
<ContentPresenter VerticalAlignment="Center" Margin="8,0,0,0"/>
</StackPanel>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="box" Property="Background" Value="{StaticResource AccentGradient}"/>
<Setter TargetName="box" Property="BorderBrush" Value="Transparent"/>
<Setter TargetName="check" Property="Visibility" Value="Visible"/>
</Trigger>
<Trigger Property="IsMouseOver" Value="True"><Setter TargetName="box" Property="BorderBrush" Value="{StaticResource AccentBrush}"/></Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== ComboBox (어두운 테마, 카드 화질 선택용) ===== -->
<Style x:Key="DarkComboBox" TargetType="ComboBox">
<Setter Property="FontFamily" Value="{StaticResource UiFont}"/>
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Background" Value="{StaticResource GlassBg}"/>
<Setter Property="BorderBrush" Value="{StaticResource BorderBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
<Setter Property="Padding" Value="10,6"/>
<Setter Property="FontSize" Value="12"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="ItemContainerStyle">
<Setter.Value>
<Style TargetType="ComboBoxItem">
<Setter Property="Foreground" Value="{StaticResource TextPrimary}"/>
<Setter Property="Padding" Value="10,5"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBoxItem">
<Border x:Name="ibd" Background="Transparent" CornerRadius="5" Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ibd" Property="Background" Value="{StaticResource AccentFadeBrush}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="ibd" Property="Background" Value="{StaticResource AccentFadeBrush}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ComboBox">
<Grid>
<Border x:Name="MainBorder" Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}" CornerRadius="8"/>
<Grid Margin="{TemplateBinding Padding}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<ToggleButton x:Name="ToggleButton" Grid.ColumnSpan="2" Focusable="False" ClickMode="Press"
Background="Transparent"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Border Background="{TemplateBinding Background}"/>
</ControlTemplate>
</ToggleButton.Template>
</ToggleButton>
<ContentPresenter x:Name="ContentSite" Grid.Column="0" IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
VerticalAlignment="Center" HorizontalAlignment="Stretch"
TextBlock.Foreground="{TemplateBinding Foreground}"
TextBlock.FontSize="{TemplateBinding FontSize}"/>
<TextBlock x:Name="arrow" Grid.Column="1" Text="&#xE70D;" Style="{StaticResource FluentIcon}"
FontSize="9" Foreground="{StaticResource TextMuted}"
VerticalAlignment="Center" Margin="10,0,0,0" IsHitTestVisible="False"/>
</Grid>
<Popup x:Name="Popup" Placement="Bottom" IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True" Focusable="False" PopupAnimation="Slide">
<Border x:Name="DropDown" Background="{StaticResource CardBg}"
BorderBrush="{StaticResource BorderBrush}" BorderThickness="1" CornerRadius="8" Padding="4"
MinWidth="{TemplateBinding ActualWidth}" MaxHeight="340">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
</ScrollViewer>
</Border>
</Popup>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsDropDownOpen" Value="True">
<Setter TargetName="MainBorder" Property="BorderBrush" Value="{StaticResource AccentBrush}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- ===== 얇은 스크롤바 ===== -->
<Style TargetType="ScrollBar">
<Setter Property="Width" Value="8"/>
<Setter Property="Background" Value="Transparent"/>
<Style.Triggers>
<Trigger Property="Orientation" Value="Vertical">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid>
<Track Name="PART_Track" IsDirectionReversed="True">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border CornerRadius="4" Background="{StaticResource BorderBrush}"/>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Trigger>
</Style.Triggers>
</Style>
</ResourceDictionary>

View file

@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<LangVersion>latest</LangVersion>
<RootNamespace>VideoDownloader</RootNamespace>
<AssemblyName>VideoDownloader</AssemblyName>
<!-- 멀티 아키텍처 배포: win-x64 + win-arm64 (self-contained 폴더). PlatformTarget 은 RID 에서 파생. -->
<Platforms>x64;arm64</Platforms>
<RuntimeIdentifiers>win-x64;win-arm64</RuntimeIdentifiers>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
<PublishReadyToRun>false</PublishReadyToRun>
<Version>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
</ItemGroup>
<!-- .NET 10 SDK 의 net8.0-windows WPF implicit usings 보강 -->
<ItemGroup>
<Using Include="System.IO" />
<Using Include="System.Net.Http" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4078.44" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,58 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using VideoDownloader.App.Detection;
using VideoDownloader.Core.Social;
namespace VideoDownloader.App.ViewModels;
public class DownloadItemViewModel : INotifyPropertyChanged
{
private string _status = "대기";
private int _progress;
private bool _isIndeterminate;
private string _segments = "";
private string? _outputPath;
private string? _error;
private FormatOption? _selectedFormat;
private bool _isFetchingFormats;
private string _title;
public string Title { get => _title; set => SetField(ref _title, value); }
public string FormatTag { get; init; } = "FILE";
// 다운로드 실행에 필요한 컨텍스트 (감지 시 고정)
public MediaKind Kind { get; init; }
public Uri MediaUrl { get; init; } = null!;
public string OutputBase { get; set; } = "";
public string? PageUrl { get; init; }
public string? ContentType { get; init; }
// 소셜(YouTube/Instagram/X) 카드: 화질/형식 선택
public bool IsSocial => Kind == MediaKind.Social;
public ObservableCollection<FormatOption> Formats { get; } = new();
public FormatOption? SelectedFormat { get => _selectedFormat; set => SetField(ref _selectedFormat, value); }
public bool IsFetchingFormats { get => _isFetchingFormats; set => SetField(ref _isFetchingFormats, value); }
public CancellationTokenSource? Cts { get; set; }
public bool Started { get; set; }
public string Status { get => _status; set => SetField(ref _status, value); }
public int Progress { get => _progress; set => SetField(ref _progress, value); }
// #9 직링크 길이 미상 시 진행률 바를 부정확(애니메이션) 모드로
public bool IsIndeterminate { get => _isIndeterminate; set => SetField(ref _isIndeterminate, value); }
public string Segments { get => _segments; set => SetField(ref _segments, value); }
public string? OutputPath { get => _outputPath; set => SetField(ref _outputPath, value); }
public string? Error { get => _error; set => SetField(ref _error, value); }
public DownloadItemViewModel(string title) { _title = title; }
public event PropertyChangedEventHandler? PropertyChanged;
private void SetField<T>(ref T field, T value, [CallerMemberName] string? name = null)
{
if (Equals(field, value)) return;
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}
}

View file

@ -0,0 +1,10 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="VideoDownloader.Avalonia.App"
RequestedThemeVariant="Default">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>

View file

@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace VideoDownloader.Avalonia;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}

View file

@ -0,0 +1,114 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Windows.Input;
using Avalonia.Threading;
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.Avalonia;
/// <summary>Phase 2 Gate #0: 크로스플랫폼 셸의 최소 뷰모델.
/// URL 을 붙여넣으면 유형(HLS/DASH/소셜/직링크) 을 판별해 Core 다운로더를 직접 호출해 진행률을 표시.
/// 브라우저/감지/쿠키(Gate #1) 는 아직 없으므로 DownloadManager 가 아닌 Core 엔진을 직접 사용.</summary>
public class MainViewModel : INotifyPropertyChanged
{
private readonly AppConfig _config = new();
private CancellationTokenSource? _cts;
private string _url = "";
private string _status = "URL 을 붙여넣고 받기를 누르세요";
private double _progress;
private bool _isBusy;
private string _result = "";
public string Url { get => _url; set { _url = value; On(); _downloadCommand.RaiseCanExecuteChanged(); } }
public string Status { get => _status; set { _status = value; On(); } }
public double Progress { get => _progress; set { _progress = value; On(); } }
public bool IsBusy { get => _isBusy; set { _isBusy = value; On(); _downloadCommand.RaiseCanExecuteChanged(); _cancelCommand.RaiseCanExecuteChanged(); } }
public string Result { get => _result; set { _result = value; On(); } }
public ObservableCollection<string> Log { get; } = new();
private readonly RelayCommand _downloadCommand;
private readonly RelayCommand _cancelCommand;
public ICommand DownloadCommand => _downloadCommand;
public ICommand CancelCommand => _cancelCommand;
public MainViewModel()
{
_downloadCommand = new RelayCommand(_ => _ = DownloadAsync(), _ => !IsBusy && !string.IsNullOrWhiteSpace(Url));
_cancelCommand = new RelayCommand(_ => Cancel(), _ => IsBusy);
}
public void Cancel() { try { _cts?.Cancel(); } catch { } Status = "취소 중..."; }
private async Task DownloadAsync()
{
if (!Uri.TryCreate(Url.Trim(), UriKind.Absolute, out var uri))
{ Status = "올바른 URL 이 아닙니다"; return; }
// UI 스레드에서 미리 유형 판별 + 상태 표시(백그라운드에서 INPC 속성을 건드리지 않도록).
var kind = Classify(uri);
IsBusy = true; Result = ""; Progress = 0; Log.Clear();
Status = kind switch { Kind.Social => "yt-dlp 소셜 다운로드", Kind.Hls => "HLS 다운로드", Kind.Dash => "DASH 다운로드", _ => "직접 다운로드" };
_cts = new CancellationTokenSource();
var ct = _cts.Token;
var progress = new Progress<DownloadProgress>(p => Dispatcher.UIThread.Post(() =>
{
if (p.OverridePercent >= 0) Progress = p.OverridePercent;
Status = !string.IsNullOrEmpty(p.Detail) ? p.Detail
: (p.Stage == "downloading" && p.Total > 0 ? $"{p.Completed}/{p.Total}" : p.Stage);
if (p.OverridePercent < 0 && !string.IsNullOrEmpty(p.Detail)) Log.Insert(0, p.Detail);
}));
try
{
var name = FileNameUtil.Sanitize(FileNameUtil.DeriveName(uri));
var outputBase = Path.Combine(_config.OutputFolder, name);
Directory.CreateDirectory(_config.OutputFolder);
string final = await Task.Run(() => DownloadByKindAsync(uri, kind, outputBase, progress, ct), ct);
Progress = 100;
Result = $"완료: {final}";
Status = "완료";
}
catch (OperationCanceledException) { Status = "취소됨"; }
catch (Exception e) { Status = "오류"; Result = $"{e.GetType().Name}: {e.Message}"; }
finally { IsBusy = false; }
}
// 백그라운드 스레드에서 실행. UI 속성 미참조(진행률은 progress 콜백→UI 디스패처로 전달).
private async Task<string> DownloadByKindAsync(Uri uri, Kind kind, string outputBase, IProgress<DownloadProgress> progress, CancellationToken ct)
{
using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
http.DefaultRequestHeaders.UserAgent.ParseAdd(_config.UserAgent);
return kind switch
{
Kind.Social => await new YtDlpDownloader(_config)
.DownloadAsync(uri, outputBase, new FormatOption("최고 화질 (MP4)", "bv*+ba/b"), null, progress, ct),
Kind.Hls => await new HlsDownloader(http, _config).DownloadAsync(uri, outputBase, progress, ct),
Kind.Dash => await new DashDownloader(http, _config).DownloadAsync(uri, outputBase, progress, ct),
_ => await new DirectDownloader(http, _config).DownloadAsync(uri, outputBase, progress, ct),
};
}
private enum Kind { Social, Hls, Dash, Direct }
private static Kind Classify(Uri uri)
{
string host = uri.Host.ToLowerInvariant(), path = uri.AbsolutePath.ToLowerInvariant();
bool social = host.Contains("youtube") || host.Contains("youtu.be") || host.Contains("instagram")
|| host.Contains("tiktok") || host.Contains("x.com") || host.Contains("twitter") || host.Contains("facebook");
if (social) return Kind.Social;
if (path.EndsWith(".m3u8")) return Kind.Hls;
if (path.EndsWith(".mpd")) return Kind.Dash;
return Kind.Direct;
}
public event PropertyChangedEventHandler? PropertyChanged;
private void On([CallerMemberName] string? p = null) => PropertyChanged?.Invoke(this, new(p));
}

View file

@ -0,0 +1,63 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:CompileBindings="False"
mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="520"
x:Class="VideoDownloader.Avalonia.MainWindow"
Title="VideoDownloader (Avalonia · 크로스플랫폼 셸)"
Width="640" Height="520"
WindowStartupLocation="CenterScreen">
<!-- DataContext 는 MainWindow 생성자에서 코드비하인드로 설정(MainViewModel). -->
<Border Background="#1B1C24" Padding="24">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*">
<!-- 헤더 -->
<StackPanel Grid.Row="0" Margin="0,0,0,16">
<TextBlock Text="VideoDownloader" FontSize="22" FontWeight="SemiBold" Foreground="#E6E6EC"/>
<TextBlock Text="Avalonia 크로스플랫폼 셸 — Core 엔진 재사용 (HLS · DASH · yt-dlp 소셜 · 직링크)"
FontSize="12" Foreground="#8A8B95" Margin="0,4,0,0"/>
</StackPanel>
<!-- URL 입력 -->
<TextBox Grid.Row="1" x:Name="UrlBox"
Text="{Binding Url, UpdateSourceTrigger=PropertyChanged}"
PlaceholderText="영상 URL 붙여넣기 (YouTube/Instagram/X · m3u8 · mpd · mp4)"
Background="#26272F" Foreground="#E6E6EC"
BorderBrush="#3A3B45" BorderThickness="1" Padding="10,8" CornerRadius="8"/>
<!-- 버튼 -->
<StackPanel Grid.Row="2" Orientation="Horizontal" Margin="0,12,0,12" Spacing="8">
<Button Content="받기" Command="{Binding DownloadCommand}"
Background="#5B8DEF" Foreground="White" Padding="24,8" CornerRadius="8" FontWeight="SemiBold"/>
<Button Content="취소" Command="{Binding CancelCommand}"
Background="#3A3B45" Foreground="#E6E6EC" Padding="18,8" CornerRadius="8"/>
</StackPanel>
<!-- 진행률 -->
<ProgressBar Grid.Row="3" Value="{Binding Progress}" Maximum="100"
MinHeight="6" CornerRadius="3" Foreground="#5B8DEF" Background="#26272F" Margin="0,0,0,8"/>
<!-- 상태/결과 -->
<StackPanel Grid.Row="4" Margin="0,0,0,12">
<TextBlock Text="{Binding Status}" Foreground="#B9BAC4" FontSize="13"/>
<TextBlock Text="{Binding Result}" Foreground="#7FD1A0" FontSize="13" TextWrapping="Wrap" Margin="0,6,0,0"/>
</StackPanel>
<!-- 로그 -->
<Border Grid.Row="5" Background="#202127" CornerRadius="8" Padding="12">
<ScrollViewer>
<ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" Foreground="#8A8B95" FontSize="11" Margin="0,2"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Border>
</Grid>
</Border>
</Window>

View file

@ -0,0 +1,19 @@
using Avalonia.Controls;
namespace VideoDownloader.Avalonia;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
DataContext = new MainViewModel();
// URL 입력창에 포커스 + Enter 로 받기 실행.
UrlBox.AttachedToVisualTree += (_, _) => UrlBox.Focus();
UrlBox.KeyDown += (_, e) =>
{
if (e.Key == global::Avalonia.Input.Key.Enter && DataContext is MainViewModel vm && !vm.IsBusy && !string.IsNullOrWhiteSpace(vm.Url))
vm.DownloadCommand.Execute(null);
};
}
}

View file

@ -0,0 +1,24 @@
using Avalonia;
using System;
namespace VideoDownloader.Avalonia;
class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
#if DEBUG
.WithDeveloperTools()
#endif
.WithInterFont()
.LogToTrace();
}

View file

@ -0,0 +1,16 @@
using System.Windows.Input;
namespace VideoDownloader.Avalonia;
/// <summary>간단한 ICommand 구현(CommunityToolkit 없이 MVVM 커맨드 바인딩용).</summary>
public sealed class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Func<object?, bool>? _canExecute;
public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
{ _execute = execute; _canExecute = canExecute; }
public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
public void Execute(object? parameter) => _execute(parameter);
public event EventHandler? CanExecuteChanged;
public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}

View file

@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<!-- Core(net8.0)와 정렬 + Mac/Linux LTS 가용성. 크로스플랫폼 Avalonia 셸. -->
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>VideoDownloader.Avalonia</RootNamespace>
<AssemblyName>VideoDownloader.Avalonia</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.1.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.1.0" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
</ItemGroup>
<!-- 공유 엔진(HLS/DASH/Social yt-dlp + 자동복구/자동설치). WPF 앱과 동일 Core 재사용. -->
<ItemGroup>
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embedded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="VideoDownloader.Avalonia.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>

View file

@ -0,0 +1,93 @@
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Dash;
using VideoDownloader.Core.Hls;
using VideoDownloader.Core.Models;
// [임시] 실제 공개 DASH 매니페스트 파싱 검증: 트랙/세그먼트 구조 출력.
if (args.Length > 0 && args[0] == "--dashparse")
{
var mpdUrl = args.Length > 1 ? args[1] : "https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd";
using var h = new HttpClient();
h.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0");
var xml = await h.GetStringAsync(mpdUrl);
Console.WriteLine($"MPD {xml.Length} bytes");
var m = DashParser.Parse(xml, new Uri(mpdUrl));
if (m == null) { Console.WriteLine("PARSE NULL"); return 1; }
if (m.Video != null) Console.WriteLine($"VIDEO: init={m.Video.InitUrl} segs={m.Video.Segments.Count} first={m.Video.Segments.FirstOrDefault()?.Url}");
if (m.Audio != null) Console.WriteLine($"AUDIO: init={m.Audio.InitUrl} segs={m.Audio.Segments.Count} first={m.Audio.Segments.FirstOrDefault()?.Url}");
else Console.WriteLine("AUDIO: (없음)");
return 0;
}
// [임시] 실제 공개 DASH 종단간 다운로드 검증.
if (args.Length > 0 && (args[0] == "--dash" || args[0] == "--dashlow"))
{
var mpdUrl = args.Length > 1 ? args[1] : "https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd";
var outBase = args.Length > 2 ? args[2] : Path.Combine(Directory.GetCurrentDirectory(), "out", "dash");
Directory.CreateDirectory(Path.GetDirectoryName(outBase)!);
var dashConfig = new AppConfig { Concurrency = 8 };
using var h = new HttpClient();
h.DefaultRequestHeaders.UserAgent.ParseAdd(dashConfig.UserAgent);
h.Timeout = TimeSpan.FromMinutes(5);
var dl = new DashDownloader(h, dashConfig);
var prog = new Progress<DownloadProgress>(p =>
{
if (p.Stage == "downloading") Console.Write($"\rDASH: {p.Completed}/{p.Total} ({p.Percent,5:F1}%) ");
else Console.WriteLine($"\n단계: {p.Stage}");
});
try
{
var final = await dl.DownloadAsync(new Uri(mpdUrl), outBase, prog);
var fi = new FileInfo(final);
Console.WriteLine($"\n완료: {final} ({fi.Length / 1048576.0:F2} MB)");
return 0;
}
catch (Exception e) { Console.WriteLine($"\n오류: {e.GetType().Name}: {e.Message}"); return 1; }
}
// 엔진 하네스: 공개 샘플 HLS 를 받아 파싱→병렬다운로드→병합→ffmpeg 리먹스까지 검증.
// 사용: dotnet run --project VideoDownloader.Cli -- <m3u8 URL> [출력경로(확장자빼고)]
// 기본 URL = Mux 공개 테스트스트림(Tears of Steel 일부)
if (args.Length > 0 && args[0] is "--help" or "-h")
{
Console.WriteLine("Usage: dotnet run --project VideoDownloader.Cli -- <m3u8-url> [outputBase]");
return 0;
}
var url = args.Length > 0 ? args[0] : "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8";
var outputBase = args.Length > 1
? args[1]
: Path.Combine(Directory.GetCurrentDirectory(), "out", "sample");
Console.WriteLine($"대상: {url}");
Console.WriteLine($"출력: {outputBase}.mp4");
var config = new AppConfig { Concurrency = 8 };
using var http = new HttpClient();
http.DefaultRequestHeaders.UserAgent.ParseAdd(config.UserAgent);
http.Timeout = TimeSpan.FromMinutes(3);
var downloader = new HlsDownloader(http, config);
var progress = new Progress<DownloadProgress>(p =>
{
if (p.Stage == "downloading")
Console.Write($"\r다운로드: {p.Completed}/{p.Total} ({p.Percent,5:F1}%) ");
else
Console.WriteLine($"\n단계: {p.Stage} ...");
});
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
var final = await downloader.DownloadAsync(new Uri(url), outputBase, progress);
sw.Stop();
var fi = new FileInfo(final);
Console.WriteLine($"\n완료: {final}");
Console.WriteLine($"크기: {fi.Length / 1024.0 / 1024.0:F2} MB / 소요: {sw.Elapsed.TotalSeconds:F1}s");
return 0;
}
catch (Exception e)
{
Console.WriteLine($"\n오류: {e.GetType().Name}: {e.Message}");
return 1;
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,70 @@
namespace VideoDownloader.Core.Config;
/// <summary>앱 설정 (JSON 직렬화). %AppData%/VideoDownloader/config.json 에 저장.</summary>
public class AppConfig
{
public string OutputFolder { get; set; } = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.MyVideos), "VideoDownloader");
/// <summary>홈 버튼이 이동할 URL</summary>
public string HomeUrl { get; set; } = "https://www.google.com";
public int Concurrency { get; set; } = 8;
public int Retries { get; set; } = 6;
/// <summary>동시에 실행할 다운로드(항목) 수 상한. 세그먼트 단위 Concurrency 와 별개.</summary>
public int MaxConcurrentDownloads { get; set; } = 3;
/// <summary>감지/다운로드에 반응할 형식 키: hls/dash/mp4/webm/mkv/m4v/mov/avi/audio</summary>
public List<string> EnabledFormats { get; set; } = new()
{ "hls", "dash", "mp4", "webm", "mkv", "m4v", "mov", "avi", "audio" };
/// <summary>직링크 감지 최소 크기(MB). 이보다 작으면(포스터 등) 무시.</summary>
public long MinSizeMB { get; set; } = 2;
/// <summary>null 이면 시스템 PATH 의 ffmpeg 사용</summary>
public string? FfmpegPath { get; set; }
/// <summary>yt-dlp 실행 파일 경로(null → 번들/PATH/자동다운로드). 소셜 영상(YouTube·Instagram·X) 다운로드용.</summary>
public string? YtDlpPath { get; set; }
/// <summary>소셜 다운로드 시 로그인 콘텐츠(인스타·X·멤버 영상 등) 를 위해 브라우저 쿠키를 yt-dlp 에 전달.</summary>
public bool UseCookiesForSocial { get; set; } = true;
/// <summary>yt-dlp 가 없으면 %AppData%/VideoDownloader/tools 로 자동 다운로드.</summary>
public bool AutoInstallYtDlp { get; set; } = true;
/// <summary>YouTube 안티봇 실패(n 챌린지/PO 토큰/서명 누락 등) 감지 시 yt-dlp 를 최신 버전으로 갱신해 1회 재시도.
/// YouTube 가 난독화를 바꿀 때 yt-dlp 는 보통 몇 시간~며칠 내 대응 릴리스를 내보내므로, 최신화만으로 대부분 복구됨.</summary>
public bool AutoUpdateYtDlp { get; set; } = true;
/// <summary>Deno(JS 런타임) 실행 파일 경로(null → 번들/PATH/자동다운로드). yt-dlp 의 EJS(n 챌린지 해석) 실행에 필요.</summary>
public string? DenoPath { get; set; }
/// <summary>Deno 가 없으면 %AppData%/VideoDownloader/tools 로 자동 다운로드. YouTube n 챌린지(EJS) 해석에 필수.</summary>
public bool AutoInstallDeno { get; set; } = true;
/// <summary>소셜 카드의 기본 화질 프리셋 키: best/1080/720/480/360/audio.</summary>
public string DefaultSocialQuality { get; set; } = "best";
/// <summary>YouTube player_client 오버라이드(예: "tv,android_vr,web"). null/빈 → yt-dlp 공식 기본값 사용(권장).
/// 2026년 기준 android/ios 는 GVS/Player PO 토큰이 필요하고 쿠키를 지원하지 않으며, web_sns 는
/// yt-dlp 에 존재하지 않는 클라이언트라 비워두는 것이 가장 안정적임.</summary>
public string? YoutubePlayerClients { get; set; }
public string UserAgent { get; set; } =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
/// <summary>테마: "dark" (기본) 또는 "light".</summary>
public string Theme { get; set; } = "dark";
/// <summary>우측 패널 펼침 폭(px). 접기/펼치기 + 드래그 리사이즈 후 저장.</summary>
public double RightPanelWidth { get; set; } = 420;
/// <summary>우측 패널 접힘 여부.</summary>
public bool RightPanelCollapsed { get; set; } = false;
/// <summary>개인정보 보호(InPrivate) 모드: 쿠키·세션·localStorage 를 디스크에 저장하지 않는다. 적용 시 앱 재시작.</summary>
public bool PrivateMode { get; set; } = false;
}

View file

@ -0,0 +1,154 @@
using System.Diagnostics;
using System.Globalization;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Dash;
/// <summary>
/// DASH(MPD) 다운로더. MPD 를 사전 파싱해 (a) DRM 여부 검증, (b) 진행률용 총 길이 산출한 뒤
/// ffmpeg 에게 네이티브 DASH 입력(-i mpd)으로 맡김 — 비디오/오디오 표현 선택·조합·fMP4 처리를
/// ffmpeg가 알아서 하므로 수동 세그먼트 병합보다 훨씬 견고. 클리어(비DRM) 전용.
/// </summary>
public class DashDownloader
{
private readonly HttpClient _http;
private readonly AppConfig _config;
public DashDownloader(HttpClient http, AppConfig config) { _http = http; _config = config; }
public async Task<string> DownloadAsync(Uri mpdUrl, string outputBase,
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
{
string? dir = Path.GetDirectoryName(outputBase);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
// 사전 검증: 파싱(또는 DRM) + 총 길이.
double durationSec = 0;
try
{
var xml = await _http.GetStringAsync(mpdUrl, ct);
var manifest = DashParser.Parse(xml, mpdUrl);
if (manifest == null)
throw new InvalidOperationException("DASH 매니페스트 파싱 실패 (DRM 암호화이거나 미지원 형식)");
durationSec = manifest.DurationSec;
}
catch (OperationCanceledException) { throw; }
catch (InvalidOperationException) { throw; }
catch (Exception e)
{
throw new InvalidOperationException($"MPD 조회/파싱 실패: {e.Message}", e);
}
var ffmpeg = ResolveFfmpeg()
?? throw new InvalidOperationException("DASH 다운로드에는 ffmpeg가 필요합니다 (시스템 PATH 또는 설정의 ffmpeg 경로).");
var final = outputBase + ".mp4";
progress?.Report(new DownloadProgress(0, 0, "downloading", 0, "DASH 준비"));
bool success = false;
try
{
await RunFfmpegDashAsync(ffmpeg, mpdUrl, final, durationSec, progress, ct);
if (!File.Exists(final) || new FileInfo(final).Length == 0)
throw new InvalidOperationException("ffmpeg 출력 파일이 비었거나 없습니다.");
success = true;
progress?.Report(new DownloadProgress(0, 0, "downloading", 100, null));
return final;
}
finally
{
if (!success) { try { if (File.Exists(final)) File.Delete(final); } catch { } }
}
}
/// <summary>ffmpeg -i mpdUrl -c copy out.mp4. -progress pipe:1 로 time= 를 파싱해 진행률.</summary>
private async Task RunFfmpegDashAsync(string ffmpeg, Uri mpdUrl, string output, double durationSec,
IProgress<DownloadProgress>? progress, CancellationToken ct)
{
Process? proc = null;
try
{
var psi = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false, CreateNoWindow = true,
RedirectStandardError = true, RedirectStandardOutput = true,
};
psi.ArgumentList.Add("-y");
psi.ArgumentList.Add("-hide_banner");
psi.ArgumentList.Add("-loglevel"); psi.ArgumentList.Add("error");
psi.ArgumentList.Add("-progress"); psi.ArgumentList.Add("pipe:1");
// #6 HTTP 옵션은 -i 앞(per-input AVOption) — .NET 사전검증에 쓴 UA/Referer/쿠키를 ffmpeg 에 전달.
// 토큰/Referer 인증 CDN(maccms 계열) 에서 ffmpeg 기본 UA 로 403 이 나는 것을 방지.
AddFfmpegHttpOptions(psi, _http);
psi.ArgumentList.Add("-i"); psi.ArgumentList.Add(mpdUrl.ToString());
psi.ArgumentList.Add("-c"); psi.ArgumentList.Add("copy");
psi.ArgumentList.Add("-movflags"); psi.ArgumentList.Add("+faststart");
psi.ArgumentList.Add(output);
proc = Process.Start(psi) ?? throw new InvalidOperationException("ffmpeg 시작 실패");
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
string? line;
while ((line = await proc.StandardOutput.ReadLineAsync(ct)) != null)
{
if (line.StartsWith("out_time_ms=", StringComparison.Ordinal) && durationSec > 0)
{
if (long.TryParse(line.AsSpan("out_time_ms=".Length), out long us) && us >= 0)
{
var pct = Math.Min(99, us / 1_000_000.0 * 100.0 / durationSec);
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, $"{us / 1_000_000.0:F0}/{durationSec:F0}s"));
}
}
if (line == "progress=end") break;
}
await proc.WaitForExitAsync(ct);
var stderr = await stderrTask;
if (proc.ExitCode != 0)
{
var msg = string.IsNullOrWhiteSpace(stderr) ? $"exit {proc.ExitCode}" : stderr.Trim();
throw new InvalidOperationException($"ffmpeg DASH 다운로드 실패: {msg}");
}
}
finally
{
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
}
}
/// <summary>#6 HttpClient 에 설정된 UA/Referer/Cookie 를 ffmpeg HTTP AVOption(-i 앞) 으로 전달.
/// -user_agent/-referer/-headers: 토큰·Referer 인증 CDN(maccms 계열) 에서 403 을 막는다.</summary>
private static void AddFfmpegHttpOptions(ProcessStartInfo psi, HttpClient http)
{
var ua = http.DefaultRequestHeaders.UserAgent.ToString();
if (!string.IsNullOrWhiteSpace(ua)) { psi.ArgumentList.Add("-user_agent"); psi.ArgumentList.Add(ua); }
if (http.DefaultRequestHeaders.Referrer is { } refUri)
{
psi.ArgumentList.Add("-referer");
psi.ArgumentList.Add(refUri.IsAbsoluteUri ? refUri.AbsoluteUri : refUri.ToString());
}
if (http.DefaultRequestHeaders.TryGetValues("Cookie", out var cookies))
{
var cookieHeader = string.Join("; ", cookies);
if (!string.IsNullOrWhiteSpace(cookieHeader))
{
psi.ArgumentList.Add("-headers");
// ffmpeg -headers: 각 헤더를 \r\n 으로 종료한 전체 값.
psi.ArgumentList.Add("Cookie: " + cookieHeader + "\r\n");
}
}
}
private string? ResolveFfmpeg()
{
if (!string.IsNullOrWhiteSpace(_config.FfmpegPath) && File.Exists(_config.FfmpegPath))
return _config.FfmpegPath;
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
foreach (var d in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
{
var p = Path.Combine(d.Trim('"'), name);
if (File.Exists(p)) return p;
}
return null;
}
}

View file

@ -0,0 +1,20 @@
namespace VideoDownloader.Core.Dash;
public record DashSegment(Uri Url, int Index);
/// <summary>하나의 AdaptationSet(비디오 또는 오디오) 최고화질 표현의 세그먼트 묶음.</summary>
public sealed class DashTrack
{
public Uri? InitUrl { get; init; }
public IReadOnlyList<DashSegment> Segments { get; init; } = Array.Empty<DashSegment>();
public bool IsFmp4 => InitUrl != null;
}
/// <summary>DASH(MPD) 파싱 결과 — 클리어(비DRM) 비디오(필수) + 오디오(선택) 트랙.</summary>
public sealed class DashManifest
{
public DashTrack? Video { get; init; }
public DashTrack? Audio { get; init; }
/// <summary>총 재생시간(초). 진행률 산정용.</summary>
public double DurationSec { get; init; }
}

View file

@ -0,0 +1,196 @@
using System.Globalization;
using System.Xml.Linq;
namespace VideoDownloader.Core.Dash;
/// <summary>
/// MPD(DASH 매니페스트) 파서. 클리어(비DRM) 콘텐츠의 최고화질 비디오(+오디오) 트랙을 구성.
/// 지원: SegmentTemplate($Number$/SegmentTimeline/duration) + SegmentList + BaseURL 누적.
/// 미지원: DRM(ContentProtection), SegmentBase(바이트레인지), 다중 Period.
/// 네임스페이스 무관(LocalName 기반 조회).
/// </summary>
public static class DashParser
{
public static DashManifest? Parse(string xml, Uri mpdUrl, bool lowestRep = false)
{
XDocument doc;
try { doc = XDocument.Parse(xml); }
catch { return null; }
var mpd = doc.Root;
if (mpd == null || mpd.Name.LocalName != "MPD") return null;
var period = Child(mpd, "Period");
if (period == null || Child(period, "ContentProtection") != null) return null; // DRM
var mpdBase = Combine(mpdUrl, Val(Child(mpd, "BaseURL")));
var periodBase = Combine(mpdBase, Val(Child(period, "BaseURL")));
// 세그먼트 수 산정용 기간: Period@duration > MPD@mediaPresentationDuration > MPD@duration
var periodSec = ParseDuration(Attr(period, "duration"));
if (periodSec <= 0) periodSec = ParseDuration(Attr(mpd, "mediaPresentationDuration"));
if (periodSec <= 0) periodSec = ParseDuration(Attr(mpd, "duration"));
DashTrack? video = null, audio = null;
foreach (var s in Children(period, "AdaptationSet"))
{
if (Child(s, "ContentProtection") != null) continue; // DRM 셋
var ct = (Attr(s, "contentType") ?? Attr(s, "mimeType") ?? "").ToLowerInvariant();
var track = BuildTrack(s, periodSec, periodBase, lowestRep);
if (track == null) continue;
if (video == null && ct.Contains("video")) video = track;
else if (audio == null && ct.Contains("audio")) audio = track;
else if (video == null) video = track; // contentType 없으면 첫 트랙을 비디오로
}
if (video == null) return null;
return new DashManifest { Video = video, Audio = audio, DurationSec = periodSec };
}
/// <summary>하나의 AdaptationSet 에서 최고(또는 최저)화질 비DRM 표현을 골라 세그먼트 묶음 생성.</summary>
private static DashTrack? BuildTrack(XElement set, double periodSec, Uri periodBase, bool lowestRep)
{
var setBase = Combine(periodBase, Val(Child(set, "BaseURL")));
var setTemplate = Child(set, "SegmentTemplate");
XElement? rep = null; long bestBw = -1;
foreach (var r in Children(set, "Representation"))
{
if (Child(r, "ContentProtection") != null) continue;
var bw = (long?)r.Attribute("bandwidth") ?? 0;
if (rep == null || (lowestRep ? bw < bestBw : bw > bestBw)) { bestBw = bw; rep = r; }
}
if (rep == null) return null;
var repBase = Combine(setBase, Val(Child(rep, "BaseURL")));
var tmpl = Child(rep, "SegmentTemplate") ?? setTemplate;
var repId = Attr(rep, "id") ?? "0";
var bwStr = bestBw.ToString();
// SegmentList
var segList = Child(rep, "SegmentList") ?? Child(set, "SegmentList");
if (segList != null)
{
var segs = new List<DashSegment>(); int i = 0;
foreach (var su in Children(segList, "SegmentURL"))
{
var m = Attr(su, "media");
if (m != null) segs.Add(new DashSegment(Combine(repBase, m), i++));
}
var init = Attr(segList, "initialization");
return new DashTrack { InitUrl = init != null ? Combine(repBase, init) : null, Segments = segs };
}
// SegmentTemplate
if (tmpl != null)
{
var initT = Attr(tmpl, "initialization");
var mediaT = Attr(tmpl, "media");
if (string.IsNullOrEmpty(mediaT)) return null;
var startNumber = (long?)tmpl.Attribute("startNumber") ?? 1;
var tmplBase = Combine(repBase, Val(Child(tmpl, "BaseURL")));
Uri? initUrl = initT != null ? Combine(tmplBase, Subst(initT, repId, bwStr, startNumber, 0)) : null;
var segs = new List<DashSegment>();
var timeline = Child(tmpl, "SegmentTimeline");
if (timeline != null)
{
// #8 $Time$ 는 미디어 시작시간(t/d 누적) 이지 세그먼트 번호가 아님(ISO/IEC 23009-1).
// t 속성은 타임라인을 해당 절대시각으로 재설정.
// #9 r=-1 은 "Period 끝까지 반복" — count=r+1=0 으로 0개를 내던 버그 수정.
var timescale = (long?)tmpl.Attribute("timescale") ?? 1;
long endTime = periodSec > 0 ? (long)Math.Ceiling(periodSec * timescale) : long.MaxValue;
long number = startNumber; int idx = 0; long time = 0;
const long MaxSegs = 200000; // 폭주 방지 상한
foreach (var se in Children(timeline, "S"))
{
var tAttr = (long?)se.Attribute("t");
if (tAttr.HasValue) time = tAttr.Value; // t: 타임라인 재설정(절대 시각)
var d = (long?)se.Attribute("d") ?? 0;
var r = (long?)se.Attribute("r") ?? 0;
if (d <= 0) continue;
if (r < 0)
{
// r=-1: 다음 세그먼트 시작이 Period 끝(endTime) 에 달하거나 넘으면 정지.
while (idx < MaxSegs && time < endTime)
{
segs.Add(new DashSegment(Combine(tmplBase, Subst(mediaT, repId, bwStr, number, time)), idx++));
number++; time += d;
}
}
else
{
for (long k = 0; k < r + 1 && idx < MaxSegs; k++)
{
segs.Add(new DashSegment(Combine(tmplBase, Subst(mediaT, repId, bwStr, number, time)), idx++));
number++; time += d;
}
}
}
}
else
{
var timescale = (long?)tmpl.Attribute("timescale") ?? 1;
var duration = (long?)tmpl.Attribute("duration") ?? 0;
if (duration > 0)
{
long total = periodSec > 0 ? (long)Math.Ceiling(periodSec * timescale / (double)duration) : 0;
if (total <= 0) total = 1;
for (long n = 0; n < total; n++)
segs.Add(new DashSegment(Combine(tmplBase, Subst(mediaT, repId, bwStr, startNumber + n, n * duration)), (int)n));
}
}
return new DashTrack { InitUrl = initUrl, Segments = segs };
}
// BaseURL 단일 파일 표현
var repBaseOnly = Val(Child(rep, "BaseURL"));
if (!string.IsNullOrEmpty(repBaseOnly))
return new DashTrack { InitUrl = null, Segments = new[] { new DashSegment(Combine(setBase, repBaseOnly), 0) } };
return null;
}
private static XElement? Child(XElement? p, string local) =>
p?.Elements().FirstOrDefault(e => e.Name.LocalName == local);
private static IEnumerable<XElement> Children(XElement? p, string local) =>
p?.Elements().Where(e => e.Name.LocalName == local) ?? Enumerable.Empty<XElement>();
private static string? Attr(XElement e, string n) => (string?)e.Attribute(n);
private static string? Val(XElement? e) => e == null ? null : e.Value.Trim();
/// <param name="number">$Number$ 값(세그먼트 순번)</param>
/// <param name="time">$Time$ 값(미디어 시작시각, SegmentTimeline 누적 t/d 또는 n*duration)</param>
private static string Subst(string template, string repId, string bw, long number, long time)
=> template.Replace("$RepresentationID$", repId)
.Replace("$Bandwidth$", bw)
.Replace("$Number$", number.ToString())
.Replace("$Time$", time.ToString());
private static Uri Combine(Uri? baseUri, string? rel)
{
if (string.IsNullOrEmpty(rel)) return baseUri!;
if (Uri.IsWellFormedUriString(rel, UriKind.Absolute)) return new Uri(rel);
if (baseUri == null) return new Uri(rel, UriKind.Relative);
return new Uri(baseUri, rel);
}
private static double ParseDuration(string? d)
{
if (string.IsNullOrEmpty(d)) return 0;
var s = d.Trim();
if (!s.StartsWith("PT", StringComparison.OrdinalIgnoreCase)) return 0;
s = s.Substring(2);
double total = 0; var num = "";
foreach (var ch in s)
{
if (char.IsDigit(ch) || ch == '.') num += ch;
else if (ch is 'H' or 'M' or 'S' && double.TryParse(num, NumberStyles.Any, CultureInfo.InvariantCulture, out var v))
{
total += ch == 'H' ? v * 3600 : ch == 'M' ? v * 60 : v;
num = "";
}
}
return total;
}
}

View file

@ -0,0 +1,156 @@
using System.Net;
using System.Net.Http.Headers;
using System.Threading;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Hls;
/// <summary>
/// 직링크 미디어(mp4/webm/mkv/m4v/mov/m4a/mp3/ogg...) 다운로드.
/// - .part 임시파일에 쓰고 완료 시 이동
/// - Range 헤더로 이어받기 (서버 지원 시)
/// - 일시 오류 재시도 + 백오프 (영구 4xx 제외)
/// </summary>
public class DirectDownloader
{
private readonly HttpClient _http;
private readonly AppConfig _config;
public DirectDownloader(HttpClient http, AppConfig config)
{
_http = http;
_config = config;
}
public async Task<string> DownloadAsync(
Uri url, string outputBase,
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
{
string? dir = Path.GetDirectoryName(outputBase);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var part = outputBase + ".part";
Exception? last = null;
string ext = GuessExt("", url);
long total = -1;
for (int attempt = 1; attempt <= _config.Retries; attempt++)
{
ct.ThrowIfCancellationRequested();
long written = File.Exists(part) ? new FileInfo(part).Length : 0;
TimeSpan? retryAfter = null; // 서버가 Retry-After 를 주면 그만큼 대기(#12)
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, url);
if (written > 0) req.Headers.Range = new RangeHeaderValue(written, null);
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
// 이미 완전히 받은 경우
if (resp.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
return Finalize(part, outputBase, ext);
// 영구 4xx (408/429 제외) → 재시도 의미 없음
var code = (int)resp.StatusCode;
bool permanent = code >= 400 && code < 500
&& resp.StatusCode != HttpStatusCode.RequestTimeout
&& resp.StatusCode != HttpStatusCode.TooManyRequests;
// #12 429/503 은 서버가 정한 쿨다운(Retry-After) 존중 — resp 가 dispose 되기 전에 캡처
if ((code == 429 || code == 503) && resp.Headers.RetryAfter is { } ra)
retryAfter = ra.Delta ?? (ra.Date is { } d ? d - DateTimeOffset.Now : null);
resp.EnsureSuccessStatusCode();
bool partial = resp.StatusCode == HttpStatusCode.PartialContent;
if (!partial) written = 0; // 서버가 range 무시 → 처음부터
var mediaType = resp.Content.Headers.ContentType?.MediaType ?? "";
ext = GuessExt(mediaType, url);
if (resp.Content.Headers.ContentLength is long cl)
total = partial ? written + cl : cl;
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var fs = new FileStream(part, partial ? FileMode.Append : FileMode.Create,
FileAccess.Write, FileShare.None, bufferSize: 65536, useAsync: true);
var buf = new byte[65536];
int n;
while ((n = await stream.ReadAsync(buf, ct)) > 0)
{
fs.Write(buf, 0, n);
written += n;
double pct = total > 0 ? written * 100.0 / total : -1;
string detail = total > 0
? $"{written / 1048576.0:F1}/{total / 1048576.0:F1} MB"
: $"{written / 1048576.0:F1} MB";
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, detail));
}
await fs.FlushAsync(ct);
return Finalize(part, outputBase, ext);
}
catch (OperationCanceledException) { throw; }
catch (HttpRequestException hre)
{
last = hre;
var c = hre.StatusCode is { } s ? (int)s : 0;
bool perm = c >= 400 && c < 500 && c != 408 && c != 429;
if (perm || attempt == _config.Retries) break;
await DelayAsync(retryAfter, attempt, ct);
}
catch (Exception e)
{
last = e;
if (attempt == _config.Retries) break;
await DelayAsync(retryAfter, attempt, ct);
}
}
throw last!;
}
private static string Finalize(string part, string outputBase, string ext)
{
var final = outputBase + ext;
// 대상 파일이 탐색기(썸네일)/백신에 의해 잠깐 잠겨있으면 File.Move 가
// "being used by another process" 로 실패하므로 잠시 대기하며 재시도.
for (int i = 0; ; i++)
{
try { File.Move(part, final, overwrite: true); return final; }
catch (IOException) when (i < 10) { Thread.Sleep(250); }
}
}
/// <summary>#12 재시도 대기: 서버 Retry-After(있으면, 최대 60s 캡) 를 우선, 없으면 지수 백오프.</summary>
private static async Task DelayAsync(TimeSpan? retryAfter, int attempt, CancellationToken ct)
{
var delay = retryAfter ?? TimeSpan.FromMilliseconds(Math.Min(1500 * attempt, 10000));
if (delay > TimeSpan.FromSeconds(60)) delay = TimeSpan.FromSeconds(60);
if (delay < TimeSpan.Zero) delay = TimeSpan.Zero;
try { await Task.Delay(delay, ct); } catch { }
}
private static readonly string[] KnownExts =
{ ".mp4", ".webm", ".mkv", ".m4v", ".mov", ".avi", ".m4a", ".mp3", ".ogg", ".opus", ".flac", ".wav", ".ts" };
private static string GuessExt(string mediaType, Uri url)
{
var path = url.AbsolutePath.ToLowerInvariant();
foreach (var k in KnownExts)
if (path.EndsWith(k)) return k;
return mediaType.ToLowerInvariant() switch
{
"video/mp4" or "video/mp4v-es" => ".mp4",
"video/webm" => ".webm",
"video/x-matroska" => ".mkv",
"video/quicktime" => ".mov",
"video/x-msvideo" => ".avi",
"audio/mp4" or "audio/x-m4a" => ".m4a",
"audio/mpeg" => ".mp3",
"audio/ogg" or "application/ogg" => ".ogg",
"audio/webm" => ".webm",
"audio/wav" or "audio/x-wav" => ".wav",
"audio/flac" => ".flac",
_ => ".mp4",
};
}
}

View file

@ -0,0 +1,261 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Hls;
/// <summary>
/// HLS 다운로더: 마스터→최고화질 변환, 세그먼트 병렬 다운로드+이어받기,
/// AES-128 복호화, fMP4(#EXT-X-MAP) 처리, 병합, ffmpeg 리먹스.
/// </summary>
public class HlsDownloader
{
private readonly HttpClient _http;
private readonly AppConfig _config;
private readonly ConcurrentDictionary<Uri, byte[]> _keys = new();
public HlsDownloader(HttpClient http, AppConfig config)
{
_http = http;
_config = config;
}
/// <param name="outputBase">확장자 없는 출력 경로. 최종 파일은 outputBase + ".mp4"</param>
public async Task<string> DownloadAsync(
Uri playlistUrl, string outputBase,
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
{
var partsDir = outputBase + ".parts";
Directory.CreateDirectory(partsDir);
string? dir = Path.GetDirectoryName(outputBase);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
bool success = false;
try
{
// 재생목록 해석 (마스터면 최고 화질 변환 따라가기)
var parsed = M3u8Parser.Parse(await _http.GetStringAsync(playlistUrl, ct), playlistUrl);
int level = 0;
while (parsed.Type == PlaylistType.Master)
{
if (parsed.Variants.Count == 0)
throw new InvalidOperationException("마스터 재생목록에 변환이 없습니다.");
var best = parsed.Variants.OrderByDescending(v => v.Bandwidth).First();
parsed = M3u8Parser.Parse(await _http.GetStringAsync(best.Url, ct), best.Url);
if (++level > 5) throw new InvalidOperationException("재생목록 중첩이 너무 깊습니다.");
}
var segs = parsed.Segments;
if (segs.Count == 0) throw new InvalidOperationException("다운로드할 세그먼트가 없습니다.");
// fMP4 초기화 세그먼트
string? initPath = null;
if (parsed.InitSegment != null)
{
initPath = Path.Combine(partsDir, "init.fmp4");
await DownloadWithRetryAsync(parsed.InitSegment, initPath + ".tmp", ct);
File.Move(initPath + ".tmp", initPath, overwrite: true);
}
// 키 사전 로드 (merge 단계에서 동기 복호화)
foreach (var ku in segs.Select(s => s.Key?.KeyUri).Where(u => u != null).Distinct()!)
await GetKeyAsync(ku!, ct);
// 세그먼트 병렬 다운로드 (이어받기)
progress?.Report(new DownloadProgress(0, segs.Count, "downloading"));
int skipped = segs.Count(s =>
File.Exists(SegmentPath(partsDir, s)) && new FileInfo(SegmentPath(partsDir, s)).Length > 0);
var done = new StrongBox<int>(skipped);
var opts = new ParallelOptions { MaxDegreeOfParallelism = Math.Max(1, _config.Concurrency), CancellationToken = ct };
await Parallel.ForEachAsync(segs, opts, async (seg, token) =>
{
var path = SegmentPath(partsDir, seg);
if (!(File.Exists(path) && new FileInfo(path).Length > 0))
{
await DownloadWithRetryAsync(seg.Url, path + ".tmp", token);
File.Move(path + ".tmp", path, overwrite: true);
}
var d = Interlocked.Increment(ref done.Value);
progress?.Report(new DownloadProgress(d, segs.Count, "downloading"));
});
// 병합 (init + 세그먼트 순서대로, 필요시 복호화)
progress?.Report(new DownloadProgress(segs.Count, segs.Count, "merging"));
bool isFmp4 = initPath != null;
var merged = Path.GetFullPath(outputBase + ".merged" + (isFmp4 ? ".mp4" : ".ts"));
using (var outFs = File.Create(merged))
{
if (initPath != null && File.Exists(initPath))
outFs.Write(File.ReadAllBytes(initPath));
foreach (var seg in segs.OrderBy(s => s.Index))
{
var data = File.ReadAllBytes(SegmentPath(partsDir, seg));
if (seg.Key != null) data = DecryptSegment(data, seg);
outFs.Write(data);
}
}
var result = await RemuxAsync(merged, outputBase, isFmp4, progress, ct);
success = true;
return result;
}
finally
{
// #6 성공 시 parts 디렉토리 정리 (실패/취소 시 호출자가 정리)
try { if (success && Directory.Exists(partsDir)) Directory.Delete(partsDir, recursive: true); }
catch { }
}
}
private static string SegmentPath(string partsDir, Segment seg) =>
Path.Combine(partsDir, $"{seg.Index:000000}.seg");
// ---- 다운로드 / 재시도 ----
private async Task DownloadWithRetryAsync(Uri url, string path, CancellationToken ct)
{
Exception? last = null;
for (int attempt = 1; attempt <= _config.Retries; attempt++)
{
try
{
using var resp = await _http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
await using var fs = File.Create(path);
await using var stream = await resp.Content.ReadAsStreamAsync(ct);
await stream.CopyToAsync(fs, 65536, ct);
return;
}
catch (OperationCanceledException) { throw; }
catch (Exception e)
{
last = e;
if (attempt == _config.Retries) break;
try { await Task.Delay(Math.Min(1500 * attempt, 10000), ct); } catch { }
}
}
throw last!;
}
// ---- AES-128 ----
private async Task GetKeyAsync(Uri keyUri, CancellationToken ct)
{
if (_keys.ContainsKey(keyUri)) return;
var key = await _http.GetByteArrayAsync(keyUri, ct);
_keys[keyUri] = key;
}
private byte[] DecryptSegment(byte[] data, Segment seg)
{
var key = seg.Key!;
var keyBytes = _keys[key.KeyUri!];
var iv = ResolveIv(key, seg.Sequence);
using var aes = Aes.Create();
aes.Key = keyBytes;
aes.IV = iv;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.None;
using var dec = aes.CreateDecryptor();
return dec.TransformFinalBlock(data, 0, data.Length);
}
private static byte[] ResolveIv(KeyInfo key, long seq)
{
var iv = key.Iv;
if (!string.IsNullOrEmpty(iv))
{
var hex = iv.Replace("0x", "").Replace(" ", "");
return Convert.FromHexString(hex.Length % 2 == 1 ? "0" + hex : hex);
}
// IV 미지정 시 시퀀스 번호를 16바이트 big-endian 으로
var b = new byte[16];
var seqBytes = BitConverter.GetBytes(seq); // little-endian on x64
if (BitConverter.IsLittleEndian) Array.Reverse(seqBytes);
Array.Copy(seqBytes, 0, b, 16 - seqBytes.Length, seqBytes.Length);
return b;
}
// ---- 리먹스 ----
private async Task<string> RemuxAsync(
string merged, string outputBase, bool isFmp4,
IProgress<DownloadProgress>? progress, CancellationToken ct)
{
var ffmpeg = ResolveFfmpeg();
if (ffmpeg != null)
{
var final = outputBase + ".mp4";
if (Path.GetFullPath(final) == Path.GetFullPath(merged))
final = outputBase + ".out.mp4";
progress?.Report(new DownloadProgress(0, 0, "remuxing"));
try
{
await RunFfmpegAsync(ffmpeg, merged, final, ct);
if (File.Exists(final) && new FileInfo(final).Length > 0)
{
File.Delete(merged);
return final;
}
throw new InvalidOperationException("ffmpeg 출력 파일이 비었거나 없습니다.");
}
catch (OperationCanceledException) { throw; }
catch
{
// #2 리먹스 실패 → partial mp4 정리 후 예외 전파 (item.Error 로 사용자 노출)
try { if (File.Exists(final)) File.Delete(final); } catch { }
throw;
}
}
// ffmpeg 없음 → 병합본을 그대로(.ts/.mp4)
var keep = outputBase + (isFmp4 ? ".mp4" : ".ts");
File.Move(merged, keep, overwrite: true);
return keep;
}
private string? ResolveFfmpeg()
{
if (!string.IsNullOrWhiteSpace(_config.FfmpegPath) && File.Exists(_config.FfmpegPath))
return _config.FfmpegPath;
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
foreach (var d in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
{
var p = Path.Combine(d.Trim('"'), name);
if (File.Exists(p)) return p;
}
return null;
}
// #2 stderr/stdout 파이프를 동시에 드레인(데드락 방지), 종료 보장(좀비 방지), 실패 시 예외 throw
// #10 stdout 드레인 태스크도 관측 — 취소 시 미관측(faulted) 태스크가 되지 않도록 finally 에서 await.
private static async Task RunFfmpegAsync(string ffmpeg, string input, string output, CancellationToken ct)
{
Process? proc = null;
Task<string>? stdoutTask = null;
try
{
var psi = new ProcessStartInfo(ffmpeg)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
foreach (var a in new[] { "-y", "-hide_banner", "-loglevel", "error", "-i", input, "-c", "copy", output })
psi.ArgumentList.Add(a);
proc = Process.Start(psi) ?? throw new InvalidOperationException("ffmpeg 시작 실패");
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
stdoutTask = proc.StandardOutput.ReadToEndAsync(ct); // stdout 도 드레인(데드락 방지)
await proc.WaitForExitAsync(ct);
var stderr = await stderrTask;
if (proc.ExitCode != 0)
throw new InvalidOperationException($"ffmpeg 리먹스 실패(exit {proc.ExitCode}): {stderr.Trim()}");
}
finally
{
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
// #10 드레인 태스크 관측 (취소/OCE/EOF 시 폭발 무시)
try { if (stdoutTask is not null) await stdoutTask; } catch { }
}
}
}

View file

@ -0,0 +1,101 @@
using System.Text.RegularExpressions;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Hls;
/// <summary>m3u8(마스터/미디어) 재생목록 파서. Python parse_m3u8 의 C# 포팅.</summary>
public static class M3u8Parser
{
// KEY="VAL",KEY2=VAL 형태 속성 파싱
private static readonly Regex AttrRe =
new(@"(?<k>[A-Z0-9\-]+)=(\""(?<q>[^""]*)\"")|(?<k2>[A-Z0-9\-]+)=(?<v>[^,]*)",
RegexOptions.Compiled);
private static Dictionary<string, string> ParseAttrs(string s)
{
var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (Match m in AttrRe.Matches(s))
{
var key = m.Groups["k"].Success ? m.Groups["k"].Value : m.Groups["k2"].Value;
var val = m.Groups["q"].Success ? m.Groups["q"].Value : m.Groups["v"].Value;
d[key] = val;
}
return d;
}
private static string GetValue(this Dictionary<string, string> d, string key, string def = "") =>
d.TryGetValue(key, out var v) ? v : def;
public static ParsedPlaylist Parse(string text, Uri baseUrl)
{
var variants = new List<Variant>();
var segments = new List<Segment>();
Uri? init = null;
bool isMaster = false;
Dictionary<string, string>? pendingVariant = null;
KeyInfo? curKey = null;
long seq = 0;
int idx = 0;
foreach (var raw in text.Split('\n'))
{
var line = raw.Trim();
if (string.IsNullOrEmpty(line)) continue;
if (line.StartsWith("#EXT-X-STREAM-INF:"))
{
pendingVariant = ParseAttrs(line.Substring("#EXT-X-STREAM-INF:".Length));
continue;
}
if (line.StartsWith("#EXT-X-MEDIA-SEQUENCE:"))
{
if (long.TryParse(line.Split(':', 2)[1].Trim(), out var s)) seq = s;
continue;
}
if (line.StartsWith("#EXT-X-KEY:"))
{
var a = ParseAttrs(line.Substring("#EXT-X-KEY:".Length));
var method = a.GetValue("METHOD", "NONE").ToUpperInvariant();
curKey = method == "AES-128"
? new KeyInfo(
a.TryGetValue("URI", out var u) && !string.IsNullOrEmpty(u) ? new Uri(baseUrl, u) : null,
a.GetValue("IV"))
: null;
continue;
}
if (line.StartsWith("#EXT-X-MAP:"))
{
var a = ParseAttrs(line.Substring("#EXT-X-MAP:".Length));
if (a.TryGetValue("URI", out var u) && !string.IsNullOrEmpty(u))
init = new Uri(baseUrl, u);
continue;
}
if (line.StartsWith("#")) continue;
// URL 라인
var abs = new Uri(baseUrl, line);
if (pendingVariant != null)
{
int.TryParse(pendingVariant.GetValue("BANDWIDTH", "0"), out var bw);
variants.Add(new Variant(abs, bw,
pendingVariant.GetValue("RESOLUTION"), pendingVariant.GetValue("CODECS")));
pendingVariant = null;
isMaster = true;
}
else
{
segments.Add(new Segment(abs, idx, seq, curKey));
idx++; seq++;
}
}
return new ParsedPlaylist
{
Type = isMaster ? PlaylistType.Master : PlaylistType.Media,
Variants = variants,
Segments = segments,
InitSegment = init,
};
}
}

View file

@ -0,0 +1,8 @@
namespace VideoDownloader.Core.Models;
/// <summary>다운로드 진행 상태. Stage: downloading / merging / remuxing / done</summary>
public record DownloadProgress(int Completed, int Total, string Stage, double OverridePercent = -1, string? Detail = null)
{
// HLS: Completed/Total 로 계산 / 직링크: OverridePercent 사용
public double Percent => OverridePercent >= 0 ? OverridePercent : (Total > 0 ? Completed * 100.0 / Total : 0);
}

View file

@ -0,0 +1,21 @@
namespace VideoDownloader.Core.Models;
public enum PlaylistType { Master, Media }
/// <summary>마스터 재생목록의 변환(화질) 항목</summary>
public record Variant(Uri Url, int Bandwidth, string Resolution, string Codecs);
/// <summary>HLS AES-128 키 정보 (EXT-X-KEY)</summary>
public record KeyInfo(Uri? KeyUri, string? Iv);
/// <summary>미디어 세그먼트</summary>
public record Segment(Uri Url, int Index, long Sequence, KeyInfo? Key);
/// <summary>m3u8 파싱 결과</summary>
public record ParsedPlaylist
{
public PlaylistType Type { get; init; }
public IReadOnlyList<Variant> Variants { get; init; } = Array.Empty<Variant>();
public IReadOnlyList<Segment> Segments { get; init; } = Array.Empty<Segment>();
public Uri? InitSegment { get; init; } // #EXT-X-MAP (fMP4 초기화 세그먼트)
}

View file

@ -0,0 +1,51 @@
using System.Text;
namespace VideoDownloader.Core.Social;
/// <summary>
/// yt-dlp 가 읽을 수 있는 Netscape cookies.txt 형식으로 쿠키를 내보낸다.
/// 로그인/연령제한/멤버 콘텐츠(인스타·X 등) 를 위해 앱(WebView2) 의 세션 쿠키를 yt-dlp 에 전달.
/// </summary>
public static class CookieJarWriter
{
// ★ BOM 금지: Encoding.UTF8 은 EF BB BF(BOM) 를 붙이는데, yt-dlp 가 첫 줄
// "# Netscape HTTP Cookie File" 헤더를 인식하지 못해("does not look like a Netscape format
// cookies file" + "invalid length 1") 쿠키 파일 전체를 거부한다.
// → preamble 을 emit 하지 않는 UTF-8 인코딩 사용.
private static readonly Encoding NoBomUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
public static void Write(string path, IEnumerable<CookieRecord> cookies)
{
var sb = new StringBuilder();
sb.AppendLine("# Netscape HTTP Cookie File");
sb.AppendLine("# This is generated by VideoDownloader. Do not edit.");
foreach (var c in cookies)
{
// 필드: domain | flag(도메인 전체 적용) | path | secure | expiration | name | value
string domain = Sanitize(c.Domain);
if (string.IsNullOrEmpty(domain)) continue; // 도메인 없는 쿠키는 7-필드 레이아웃 보호를 위해 건너뜀
string name = Sanitize(c.Name);
string value = Sanitize(c.Value);
if (name.Length == 0 && value.Length == 0) continue;
bool subdomains = domain.StartsWith('.');
// HttpOnly 쿠키는 줄 앞에 "#HttpOnly_" 접두 — yt-dlp 가 http_only 플래그로 인식.
// (YouTube 의 __Secure-* / SID 계열 인증 쿠키가 대부분 HttpOnly.)
if (c.HttpOnly) sb.Append("#HttpOnly_");
sb.Append(domain).Append('\t');
sb.Append(subdomains ? "TRUE" : "FALSE").Append('\t');
sb.Append(string.IsNullOrEmpty(c.Path) ? "/" : Sanitize(c.Path)).Append('\t');
sb.Append(c.Secure ? "TRUE" : "FALSE").Append('\t');
sb.Append(c.Expires > 0 ? c.Expires : 0).Append('\t');
sb.Append(name).Append('\t');
sb.Append(value);
sb.AppendLine();
}
File.WriteAllText(path, sb.ToString(), NoBomUtf8);
}
/// <summary>필드 구분자(tab) 와 줄바꿈(CR/LF) 이 값 안에 들어가 7-필드 레이아웃이 깨지는 것을 방지.</summary>
private static string Sanitize(string s) =>
string.IsNullOrEmpty(s) ? s : s.Replace('\t', ' ').Replace('\r', ' ').Replace('\n', ' ');
}

View file

@ -0,0 +1,4 @@
namespace VideoDownloader.Core.Social;
/// <summary>WebView2 종속 없는 쿠키 표현. App 계층에서 CoreWebView2Cookie → CookieRecord 로 매핑.</summary>
public record CookieRecord(string Domain, string Path, string Name, string Value, bool Secure, bool HttpOnly, long Expires);

View file

@ -0,0 +1,105 @@
using System.IO.Compression;
using System.Runtime.InteropServices;
using VideoDownloader.Core.Config;
namespace VideoDownloader.Core.Social;
/// <summary>
/// Deno(JS 런타임) 해결(설정 오버라이드 → 번들 → PATH → 자동 다운로드) +
/// 안전한 다운로드(GitHub Releases zip → deno.exe 추출). yt-dlp 의 EJS(n 챌린지 해석) 실행에 필요.
/// YtDlpRunner.RunAsync 가 자식 프로세스 PATH 에 ToolsDir 를 추가하므로, 번들 deno.exe 는 yt-dlp 가 자동 발견.
/// </summary>
public static class DenoRunner
{
// yt-dlp 와 동일한 tools 디렉터리를 공유 → PATH 주입 한 번으로 yt-dlp/ffmpeg/deno 모두 발견.
public static string ToolsDir => YtDlpRunner.ToolsDir;
private static string BundledExe => OperatingSystem.IsWindows() ? "deno.exe" : "deno";
public static string BundledPath => Path.Combine(ToolsDir, BundledExe);
private static readonly HttpClient Downloader = new(new HttpClientHandler { AllowAutoRedirect = true })
{ Timeout = TimeSpan.FromMinutes(5) };
/// <summary>Deno 는 Windows ARM 네이티브 빌드가 없어 x64 에뮬레이션. macOS/Linux 는 아키텍처별 에셋.</summary>
private static string AssetName
{
get
{
bool arm = RuntimeInformation.ProcessArchitecture is Architecture.Arm64;
if (OperatingSystem.IsWindows()) return "deno-x86_64-pc-windows-msvc.zip";
if (OperatingSystem.IsMacOS()) return arm ? "deno-aarch64-apple-darwin.zip" : "deno-x86_64-apple-darwin.zip";
return arm ? "deno-aarch64-unknown-linux-gnu.zip" : "deno-x86_64-unknown-linux-gnu.zip";
}
}
private static string ReleaseUrl => $"https://github.com/denoland/deno/releases/latest/download/{AssetName}";
/// <summary>Deno 경로 해결. 못 찾으면 null.</summary>
public static string? Resolve(AppConfig config)
{
if (!string.IsNullOrWhiteSpace(config.DenoPath) && File.Exists(config.DenoPath))
return config.DenoPath;
try { if (File.Exists(BundledPath)) return BundledPath; } catch { }
return FindOnPath(OperatingSystem.IsWindows() ? "deno.exe" : "deno");
}
/// <summary>Deno 가 없고 AutoInstallDeno 면 번들 디렉터리로 자동 다운로드. 경로 반환(실패 시 null).</summary>
public static async Task<string?> EnsureInstalledAsync(AppConfig config, Action<string>? log, CancellationToken ct)
{
var found = Resolve(config);
if (found != null) return found;
if (!config.AutoInstallDeno) return null;
return await DownloadAsync(config, log, ct);
}
/// <summary>Deno 를 강제로(재)다운로드해 번들 디렉터리에 설치.</summary>
public static async Task<string?> DownloadAsync(AppConfig config, Action<string>? log, CancellationToken ct)
{
var tmpZip = BundledPath + ".zip";
try
{
Directory.CreateDirectory(ToolsDir);
log?.Invoke("Deno 다운로드 중...");
using var resp = await Downloader.GetAsync(ReleaseUrl, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
await using (var src = await resp.Content.ReadAsStreamAsync(ct))
await using (var dst = new FileStream(tmpZip, FileMode.Create, FileAccess.Write, FileShare.None, 65536, useAsync: true))
{
await src.CopyToAsync(dst, 65536, ct);
await dst.FlushAsync(ct);
}
// zip 에서 deno 실행파일 엔트리만 추출(압축 풀면 deno/deno.exe 단일 파일).
try { if (File.Exists(BundledPath)) File.Delete(BundledPath); } catch { }
using var archive = new ZipArchive(new FileStream(tmpZip, FileMode.Open, FileAccess.Read, FileShare.None, 65536, useAsync: true), ZipArchiveMode.Read);
var entry = archive.Entries.FirstOrDefault(e => e.Name.Equals(BundledExe, StringComparison.OrdinalIgnoreCase));
if (entry == null) { log?.Invoke("Deno 추출 실패: zip 에 실행파일이 없음"); return null; }
await using var es = entry.Open();
await using var fs = new FileStream(BundledPath, FileMode.Create, FileAccess.Write, FileShare.None, 65536, useAsync: true);
await es.CopyToAsync(fs, 65536, ct);
await fs.FlushAsync(ct);
log?.Invoke($"Deno 설치 완료: {BundledPath}");
return BundledPath;
}
catch (Exception e)
{
log?.Invoke($"Deno 다운로드 실패: {e.Message}");
return null;
}
finally
{
try { if (File.Exists(tmpZip)) File.Delete(tmpZip); } catch { }
}
}
private static string? FindOnPath(string name)
{
foreach (var d in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
{
var p = Path.Combine(d.Trim('"'), name);
if (File.Exists(p)) return p;
}
return null;
}
}

View file

@ -0,0 +1,8 @@
namespace VideoDownloader.Core.Social;
/// <summary>yt-dlp 화질/형식 선택 옵션. Selector 는 yt-dlp 의 -f 인자.</summary>
/// <param name="Label">드롭다운에 표시할 이름(예: "1080p")</param>
/// <param name="Selector">yt-dlp 포맷 셀렉터(예: "bv*[height&lt;=1080]+ba/b")</param>
/// <param name="IsAudio">true 면 오디오만 추출(--extract-audio --audio-format mp3)</param>
/// <param name="Source">"preset"(정적) 또는 "fetched"(정밀 조회 결과)</param>
public record FormatOption(string Label, string Selector, bool IsAudio = false, string Source = "preset");

View file

@ -0,0 +1,28 @@
namespace VideoDownloader.Core.Social;
/// <summary>정적 화질 프리셋. 카드에 즉시 표시(네트워크 조회 불필요). yt-dlp 포맷 셀렉터 기반.</summary>
public static class SocialPresets
{
public static readonly List<FormatOption> All = new()
{
new("최고 화질 (MP4)", "bv*+ba/b", IsAudio: false),
new("1080p", "bv*[height<=1080]+ba/b[height<=1080]/b[height<=1080]"),
new("720p", "bv*[height<=720]+ba/b[height<=720]/b[height<=720]"),
new("480p", "bv*[height<=480]+ba/b[height<=480]/b[height<=480]"),
new("360p", "bv*[height<=360]+ba/b[height<=360]/b[height<=360]"),
new("오디오만 (MP3)", "ba", IsAudio: true),
};
/// <summary>설정의 기본 화질 키(best/1080/720/480/360/audio) 로 기본 옵션을 찾는다.</summary>
public static FormatOption DefaultFor(string? key)
{
var k = (key ?? "best").Trim().ToLowerInvariant();
foreach (var o in All)
{
if (k == "best" && o.Label.StartsWith("최고")) return o;
if (k == "audio" && o.IsAudio) return o;
if (o.Label.StartsWith(k + "p", StringComparison.Ordinal)) return o;
}
return All[0];
}
}

View file

@ -0,0 +1,259 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Models;
namespace VideoDownloader.Core.Social;
/// <summary>
/// yt-dlp 기반 소셜 영상 다운로더(YouTube/Shorts/Instagram/X/TikTok/Facebook).
/// 기존 HLS/DASH/Direct 다운로더 계약을 따른다(단, 페이지 URL 을 받는다).
/// 진행률은 yt-dlp --newline 의 [download] 라인에서 퍼센트를 파싱.
/// </summary>
public class YtDlpDownloader
{
private readonly AppConfig _config;
// [download] 12.3% of ~2.34MiB ... → (percent, size)
private static readonly Regex ProgressRe =
new(@"\[download\]\s+([0-9.]+)%\s+of\s+(\S+)", RegexOptions.Compiled);
public YtDlpDownloader(AppConfig config) => _config = config;
/// <param name="pageUrl">영상 페이지 URL</param>
/// <param name="outputBase">확장자 없는 출력 경로</param>
/// <param name="fmt">선택한 화질/형식 옵션</param>
/// <param name="cookiesPath">Netscape cookies.txt 경로(null 이면 미사용)</param>
public async Task<string> DownloadAsync(
Uri pageUrl, string outputBase, FormatOption fmt, string? cookiesPath,
IProgress<DownloadProgress>? progress = null, CancellationToken ct = default)
{
string? dir = Path.GetDirectoryName(outputBase);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var exe = await YtDlpRunner.EnsureInstalledAsync(_config, null, ct)
?? throw new InvalidOperationException("yt-dlp 를 찾을 수 없습니다. 설정에서 경로를 지정하거나 PATH 에 추가하세요.");
var ffmpegDir = YtDlpRunner.ResolveFfmpegDir(_config);
// YouTube n 챌린지(EJS) 해석용 JS 런타임 확보. 없고 AutoInstallDeno 면 자동 설치.
// Deno 누락이 치명적 오류는 아니므로(yt-dlp 가 경고 후 진행) 결과는 무시 — PATH 주입(YtDlpRunner) 으로 자동 발견.
void ReportStatus(string msg) => progress?.Report(new DownloadProgress(0, 0, "downloading", -1, msg));
await DenoRunner.EnsureInstalledAsync(_config, ReportStatus, ct);
var args = BuildArgs(pageUrl, outputBase, fmt, cookiesPath, ffmpegDir, _config.YoutubePlayerClients);
// yt-dlp 실행 + 진행률 파싱. 갱신 후 재시도 시 동일 콜백을 재사용하므로 로컬 함수로 캡슐화.
// (remuxing 은 실행마다 리셋 — 갱신 후 재시도에선 병합 단계가 처음부터 다시 시작될 수 있음.)
async Task<(int Exit, StringBuilder Err)> RunOnceAsync(string exePath)
{
bool remuxing = false;
var err = new StringBuilder();
int exit = await YtDlpRunner.RunAsync(exePath, args, (stream, line) =>
{
if (stream == "err") err.AppendLine(line); // 실패 시 원인 노출용
if (line.StartsWith("[Merger]") || line.StartsWith("[ExtractAudio]")
|| line.Contains("Merging formats") || line.Contains("Deleting original file"))
{
remuxing = true;
progress?.Report(new DownloadProgress(0, 0, "remuxing", -1, "병환/변환 중"));
return;
}
var m = ProgressRe.Match(line);
if (m.Success)
{
double pct = double.TryParse(m.Groups[1].Value, out var p) ? p : 0;
string size = m.Groups[2].Value;
progress?.Report(new DownloadProgress(0, 0, "downloading", pct, $"{pct:0}% · {size}"));
}
else if (!remuxing)
{
// [download] Destination 등: 길이 미상 부정확 모드
progress?.Report(new DownloadProgress(0, 0, "downloading", -1, line.Trim()));
}
}, ct);
return (exit, err);
}
var (result, errLog) = await RunOnceAsync(exe);
string? recoverNote = null;
// 자동 복구: YouTube 안티봇(n 챌린지/PO 토큰/서명 누락 등) 실패로 보이면 yt-dlp 를 최신화해 1회 재시도.
// YouTube 가 서명 난독화를 바꿀 때마다 yt-dlp 유지보수자가 대응 릴리스를 내놓으므로, 최신화만으로 대부분 복구됨.
if (result != 0 && _config.AutoUpdateYtDlp && LooksLikeAntiBotFailure(errLog))
{
progress?.Report(new DownloadProgress(0, 0, "downloading", -1, "YouTube 차단 감지 — yt-dlp 갱신 확인 중"));
var current = await YtDlpRunner.GetVersionAsync(exe, ct);
var latest = await YtDlpRunner.GetLatestVersionAsync(ct);
if (latest != null && latest != current)
{
progress?.Report(new DownloadProgress(0, 0, "downloading", -1,
$"yt-dlp {current ?? "?"} → {latest} 갱신 후 재시도"));
var updated = await YtDlpRunner.DownloadAsync(_config, null, ct);
if (updated != null)
{
exe = updated; // Resolve 우선순위상 갱신된 번들 바이너리가 다음 호출부터 사용됨
(result, errLog) = await RunOnceAsync(exe);
}
}
else if (latest != null) // latest == current: 이미 최신인데도 실패 → 갱신은 무의미
{
recoverNote = $"이미 최신 yt-dlp({current})임에도 실패 — YouTube가 방금 바뀌었을 수 있음(잠시 후 재시도 권장)";
}
}
if (result != 0)
{
// stderr 마지막 6줄까지 노출 → 사용자가 실제 사유(봇체크/연령/쿠키 등) 를 바로 알 수 있게.
var tail = errLog.ToString().Trim();
var shortMsg = tail.Length == 0 ? "(stderr 없음)" : string.Join(' ', tail.Split('\n').TakeLast(6)).Trim();
var msg = $"yt-dlp 실패(exit {result}): {shortMsg}";
if (recoverNote != null) msg += $" | {recoverNote}";
throw new InvalidOperationException(msg);
}
var final = outputBase + (fmt.IsAudio ? ".mp3" : ".mp4");
if (!File.Exists(final))
{
// 예상과 다른 확장자(webm/mkv 등) 로 떨어졌을 가능성 → outputBase.* 중 가장 최근 파일
var alt = FindProduced(outputBase);
if (alt == null)
throw new InvalidOperationException("출력 파일을 찾을 수 없습니다. yt-dlp 로그를 확인하세요.");
final = alt;
}
return final;
}
private static List<string> BuildArgs(Uri pageUrl, string outputBase, FormatOption fmt,
string? cookiesPath, string? ffmpegDir, string? playerClients)
{
var args = new List<string>
{
"--no-playlist",
"--no-update",
"--newline",
"-f", fmt.Selector,
};
if (fmt.IsAudio)
{
args.AddRange(new[] { "--extract-audio", "--audio-format", "mp3", "--audio-quality", "0" });
}
else
{
args.AddRange(new[] { "--merge-output-format", "mp4" });
}
if (ffmpegDir != null) args.AddRange(new[] { "--ffmpeg-location", ffmpegDir });
if (!string.IsNullOrEmpty(cookiesPath)) args.AddRange(new[] { "--cookies", cookiesPath });
// YouTube player_client: 비워두면 yt-dlp 공식 기본값(PO 토큰에 안전한 visionos/android_vr/web, 유지보수자가 갱신) 사용.
// 과거엔 android/web_sns/web 하드코딩으로 덮었으나, 2024년 이후 android/ios 는 PO 토큰 필수 + 쿠키 미지원,
// web_sns 는 yt-dlp 에 없는 클라이언트라 대부분 무효 → 기본값보다 오히려 나빴음.
// 로그인/연령제한 콘텐츠는 (BOM 수정된) 쿠키가 web 클라이언트로 전달되어 정상 작동.
AddYoutubeClientArgs(args, playerClients);
// YouTube n 챌린지(플레이어 서명 스로틀 파라미터 난독화) 해석 활성화. 없으면 포맷이 통째로 드랍되어
// "Only images are available" 로 MP4 다운로드 실패하거나, 살아남아도 수백 KiB/s 로 심하게 스로틀됨.
AddRemoteComponentsArgs(args);
args.AddRange(new[] { "-o", outputBase + ".%(ext)s" });
args.Add(pageUrl.ToString());
return args;
}
/// <summary>YouTube n 챌린지 해석용 EJS(External JS Solver) 리모트 컴포넌트 활성화.
/// yt-dlp 가 GitHub 배포 챌린지 솔버 스크립트를 받아 JS 런타임(Deno, PATH 에 있으면 자동 감지) 으로 실행.
/// yt-dlp 공식 권장 설정(https://github.com/yt-dlp/yt-dlp/wiki/EJS). 최초 1회만 다운로드 후 캐싱.</summary>
private static void AddRemoteComponentsArgs(List<string> args) =>
args.AddRange(new[] { "--remote-components", "ejs:github" });
/// <summary>YouTube player_client 오버라이드. null/빈 → 인자 생략 → yt-dlp 공식 기본값 사용(권장).
/// 임의 값 지정 시 android/ios(PO 토큰 필요·쿠키 미지원) 와 존재하지 않는 web_sns 는 피할 것.</summary>
private static void AddYoutubeClientArgs(List<string> args, string? clients)
{
if (string.IsNullOrWhiteSpace(clients)) return; // 기본값 위임
args.AddRange(new[] { "--extractor-args", $"youtube:player_client={clients.Trim()}" });
}
/// <summary>stderr 에서 YouTube 안티봇(n 챌린지/PO 토큰/서명) 실패 징후를 찾는다.
/// yt-dlp 최신화로 복구 가능성이 높은 실패만 true → 진짜 가용성/설정 문제는 갱신·재시도를 피함.</summary>
private static bool LooksLikeAntiBotFailure(StringBuilder err)
{
var s = err.ToString();
if (string.IsNullOrWhiteSpace(s)) return false;
return s.Contains("n challenge", StringComparison.OrdinalIgnoreCase) // n 챌린지 해석 실패
|| s.Contains("nsig", StringComparison.OrdinalIgnoreCase) // n signature 추출 실패
|| s.Contains("Only images are available", StringComparison.OrdinalIgnoreCase) // 포맷 통째 드랍
|| s.Contains("PO Token", StringComparison.OrdinalIgnoreCase) // PO 토큰 요구
|| s.Contains("po_token", StringComparison.OrdinalIgnoreCase)
|| s.Contains("Requested format is not available", StringComparison.OrdinalIgnoreCase)
|| s.Contains("Unable to extract", StringComparison.OrdinalIgnoreCase); // 서명/player 추출 실패
}
private static string? FindProduced(string outputBase)
{
var dir = Path.GetDirectoryName(outputBase);
var name = Path.GetFileName(outputBase);
if (string.IsNullOrEmpty(dir)) return null;
FileInfo? best = null;
foreach (var f in new DirectoryInfo(dir).EnumerateFiles(name + ".*"))
{
// yt-dlp 잔류(fragment/.part) 제외
if (f.Name.EndsWith(".part", StringComparison.OrdinalIgnoreCase) ||
f.Name.EndsWith(".ytdl", StringComparison.OrdinalIgnoreCase)) continue;
if (best == null || f.LastWriteTime > best.LastWriteTime) best = f;
}
return best?.FullName;
}
/// <summary>yt-dlp -J 로 실제 사용 가능한 포맷을 조회해 화질 옵션을 만든다(정밀 드롭다운용).</summary>
public async Task<List<FormatOption>> FetchFormatsAsync(Uri pageUrl, string? cookiesPath, CancellationToken ct)
{
var exe = await YtDlpRunner.EnsureInstalledAsync(_config, null, ct);
if (exe == null) return new();
var args = new List<string> { "-J", "--no-playlist" };
if (!string.IsNullOrEmpty(cookiesPath)) args.AddRange(new[] { "--cookies", cookiesPath });
AddYoutubeClientArgs(args, _config.YoutubePlayerClients);
AddRemoteComponentsArgs(args); // n 챌린지 미해석 시 일부 포맷이 누락될 수 있어 동일하게 활성화
args.Add(pageUrl.ToString());
try
{
var (exit, stdout, _) = await YtDlpRunner.RunCaptureAsync(exe, args, ct);
if (exit != 0 || string.IsNullOrWhiteSpace(stdout)) return new();
return ParseFormats(stdout);
}
catch { return new(); }
}
private static List<FormatOption> ParseFormats(string json)
{
var result = new List<FormatOption>();
try
{
using var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("formats", out var formats)) return result;
var heights = new SortedSet<int>(Comparer<int>.Create((a, b) => b.CompareTo(a)));
foreach (var f in formats.EnumerateArray())
{
string vcodec = Get(f, "vcodec");
if (vcodec == "none" || vcodec == "") continue; // 오디오 전용 제외
if (!f.TryGetProperty("height", out var h)) continue;
int height = h.ValueKind == JsonValueKind.Number ? h.GetInt32() : 0;
if (height > 0) heights.Add(height);
}
result.Add(new FormatOption("최고 화질 (MP4)", "bv*+ba/b"));
foreach (var hh in heights)
{
// 50fps 이상이면 라벨에 표기(대략). 정확 fps 매칭은 셀렉터로 보장되지 않아 정보용.
string label = hh >= 1280 ? $"{hh}p" : $"{hh}p";
result.Add(new FormatOption(label, $"bv*[height<={hh}]+ba/b[height<={hh}]"));
}
result.Add(new FormatOption("오디오만 (MP3)", "ba", IsAudio: true));
}
catch { }
return result.Count > 1 ? result : new();
}
private static string Get(JsonElement obj, string name) =>
obj.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : "";
}

View file

@ -0,0 +1,214 @@
using System.Diagnostics;
using System.Text.Json;
using VideoDownloader.Core.Config;
namespace VideoDownloader.Core.Social;
/// <summary>
/// yt-dlp 실행 파일 해결(설정 오버라이드 → 번들 → PATH → 자동 다운로드) +
/// 안전한 프로세스 실행(stdout/stderr 라인 단위 동시 드레인, 취소 시 트리 킬, exit 검증).
/// HlsDownloader.RunFfmpegAsync(#2 패턴) 과 동일한 안전 규칙을 따른다.
/// </summary>
public static class YtDlpRunner
{
public static string ToolsDir => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "VideoDownloader", "tools");
private static string BundledPath => Path.Combine(ToolsDir,
OperatingSystem.IsWindows() ? "yt-dlp.exe" : "yt-dlp");
private static readonly HttpClient Downloader = new(new HttpClientHandler { AllowAutoRedirect = true })
{ Timeout = TimeSpan.FromMinutes(5) };
private const string ReleaseUrl = "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe";
// GitHub Releases API — 최신 태그(버전) 조회용. yt-dlp 태그는 "2026.03.17" 형식(--version 출력과 동일).
private const string LatestReleaseApiUrl = "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest";
/// <summary>yt-dlp 경로를 해결한다. 못 찾으면 null.</summary>
public static string? Resolve(AppConfig config)
{
if (!string.IsNullOrWhiteSpace(config.YtDlpPath) && File.Exists(config.YtDlpPath))
return config.YtDlpPath;
try { if (File.Exists(BundledPath)) return BundledPath; } catch { }
return FindOnPath(OperatingSystem.IsWindows() ? "yt-dlp.exe" : "yt-dlp");
}
/// <summary>yt-dlp 가 없고 AutoInstallYtDlp 면 번들 디렉토리로 자동 다운로드. 경로 반환(실패 시 null).</summary>
public static async Task<string?> EnsureInstalledAsync(AppConfig config, Action<string>? log, CancellationToken ct)
{
var found = Resolve(config);
if (found != null) return found;
if (!config.AutoInstallYtDlp) return null;
return await DownloadAsync(config, log, ct);
}
/// <summary>yt-dlp 를 강제로(재)다운로드해 번들 디렉토리에 설치.</summary>
public static async Task<string?> DownloadAsync(AppConfig config, Action<string>? log, CancellationToken ct)
{
try
{
Directory.CreateDirectory(ToolsDir);
try { if (File.Exists(BundledPath)) File.Delete(BundledPath); } catch { }
var tmp = BundledPath + ".tmp";
log?.Invoke("yt-dlp 다운로드 중...");
using var resp = await Downloader.GetAsync(ReleaseUrl, HttpCompletionOption.ResponseHeadersRead, ct);
resp.EnsureSuccessStatusCode();
await using var src = await resp.Content.ReadAsStreamAsync(ct);
await using var dst = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None, 65536, useAsync: true);
await src.CopyToAsync(dst, 65536, ct);
await dst.FlushAsync(ct);
File.Move(tmp, BundledPath, overwrite: true);
log?.Invoke($"yt-dlp 설치 완료: {BundledPath}");
return BundledPath;
}
catch (Exception e)
{
log?.Invoke($"yt-dlp 다운로드 실패: {e.Message}");
return null;
}
}
/// <summary>설치된 yt-dlp 버전 문자열("2026.03.17"). 알 수 없으면 null.</summary>
public static async Task<string?> GetVersionAsync(string exe, CancellationToken ct)
{
try
{
var (exit, stdout, _) = await RunCaptureAsync(exe, new[] { "--version" }, ct);
if (exit != 0) return null;
var first = stdout.Trim().Split('\n', StringSplitOptions.TrimEntries)[0];
return string.IsNullOrWhiteSpace(first) ? null : first;
}
catch { return null; }
}
/// <summary>GitHub Releases 의 최신 yt-dlp 버전(태그). 조회 실패 시 null.</summary>
public static async Task<string?> GetLatestVersionAsync(CancellationToken ct)
{
try
{
using var req = new HttpRequestMessage(HttpMethod.Get, LatestReleaseApiUrl);
req.Headers.UserAgent.ParseAdd("VideoDownloader"); // GitHub API 요구 헤더
req.Headers.Accept.ParseAdd("application/vnd.github+json");
using var resp = await Downloader.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("tag_name", out var tag) &&
tag.ValueKind == JsonValueKind.String)
return tag.GetString()?.Trim().TrimStart('v'); // 태그 "v2026.03.17" → "2026.03.17"
return null;
}
catch { return null; }
}
/// <summary>ffmpeg 위치(디렉토리) 를 찾는다(--ffmpeg-location). 없으면 null.</summary>
public static string? ResolveFfmpegDir(AppConfig config)
{
string? exe = null;
if (!string.IsNullOrWhiteSpace(config.FfmpegPath) && File.Exists(config.FfmpegPath))
exe = config.FfmpegPath;
else
{
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
exe = FindOnPath(name);
}
return exe == null ? null : Path.GetDirectoryName(exe);
}
private static string? FindOnPath(string name)
{
foreach (var d in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator))
{
var p = Path.Combine(d.Trim('"'), name);
if (File.Exists(p)) return p;
}
return null;
}
/// <summary>yt-dlp 자식 프로세스 PATH 앞에 ToolsDir 를 추가 → 번들 deno.exe(EJS n 챌린지 해석용) 를 yt-dlp 가 자동 발겑.
/// 시스템 PATH 는 건드리지 않고 이 프로세스에만 적용. ffmpeg/yt-dlp 등 다른 번들 도구 발견에도 보조.</summary>
private static void PrependToolsToPath(ProcessStartInfo psi)
{
try
{
var cur = psi.Environment.TryGetValue("PATH", out var p) ? (p ?? "") : "";
psi.Environment["PATH"] = ToolsDir + Path.PathSeparator + cur;
}
catch { } // UseShellExecute=true 등 예외 시 무시(본 프로젝트에선 항상 false)
}
/// <summary>프로세스 실행. onLine(stream, line): stream="out"|"err". 취소 시 트리 킬.</summary>
public static async Task<int> RunAsync(string exe, IEnumerable<string> args,
Action<string, string> onLine, CancellationToken ct)
{
Process? proc = null;
try
{
var psi = new ProcessStartInfo(exe)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
PrependToolsToPath(psi); // 번들 deno.exe 를 yt-dlp 가 발견하도록(EJS n 챌린지 해석)
proc = Process.Start(psi) ?? throw new InvalidOperationException("yt-dlp 시작 실패");
await using var _ = ct.Register(() => { try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { } });
// stdout/stderr 를 별개 태스크로 동시 드레인(파이프 버퍼 차면 데드락).
var outTask = ReadLinesAsync(proc.StandardOutput, "out", onLine, ct);
var errTask = ReadLinesAsync(proc.StandardError, "err", onLine, ct);
await proc.WaitForExitAsync(ct);
await Task.WhenAll(outTask, errTask);
return proc.ExitCode;
}
finally
{
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
}
}
/// <summary>stdout 전체를 하나의 문자열로 캡처(-J JSON 덤프용). stderr 도 버퍼링하며 취소 시 트리 킬.</summary>
public static async Task<(int Exit, string Stdout, string Stderr)> RunCaptureAsync(
string exe, IEnumerable<string> args, CancellationToken ct)
{
Process? proc = null;
try
{
var psi = new ProcessStartInfo(exe)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
PrependToolsToPath(psi); // 번들 deno.exe 를 yt-dlp 가 발견하도록(EJS n 챌린지 해석)
proc = Process.Start(psi) ?? throw new InvalidOperationException("yt-dlp 시작 실패");
await using var _ = ct.Register(() => { try { if (!proc.HasExited) proc.Kill(entireProcessTree: true); } catch { } });
var stdoutTask = proc.StandardOutput.ReadToEndAsync(ct);
var stderrTask = proc.StandardError.ReadToEndAsync(ct);
await proc.WaitForExitAsync(ct);
return (proc.ExitCode, await stdoutTask, await stderrTask);
}
finally
{
try { if (proc is { HasExited: false }) proc.Kill(entireProcessTree: true); } catch { }
}
}
private static async Task ReadLinesAsync(StreamReader reader, string stream, Action<string, string> onLine, CancellationToken ct)
{
string? line;
while ((line = await reader.ReadLineAsync(ct)) != null)
{
ct.ThrowIfCancellationRequested();
if (!string.IsNullOrWhiteSpace(line)) onLine(stream, line);
}
}
}

View file

@ -0,0 +1,50 @@
using System.Text;
namespace VideoDownloader.Core.Util;
public static class FileNameUtil
{
private static readonly char[] Invalid = Path.GetInvalidFileNameChars();
// 무의미한 제네릭 이름 (이름 추출 시 건너뜀)
private static readonly HashSet<string> GenericNames = new(StringComparer.OrdinalIgnoreCase)
{
"index", "playlist", "playlists", "master", "chunklist", "main", "play",
"media", "video", "stream", "streaming", "vod", "output", "file", "hls",
"dash", "manifest", "seg", "segment", "test", "default"
};
/// <summary>파일명으로 사용 불가능한 문자를 '_'로 치환하고 길이를 제한한다.</summary>
public static string Sanitize(string? name, int maxLen = 120)
{
if (string.IsNullOrWhiteSpace(name)) return "video";
var extra = new[] { '"', '<', '>', '|', '*', '?' };
var sb = new StringBuilder();
foreach (var c in name.Trim())
{
if (Array.IndexOf(Invalid, c) >= 0 || Array.IndexOf(extra, c) >= 0) sb.Append('_');
else sb.Append(c);
}
var result = sb.ToString().Trim().Trim('.');
if (string.IsNullOrWhiteSpace(result)) result = "video";
return result.Length > maxLen ? result[..maxLen].Trim() : result;
}
/// <summary>미디어 URL 에서 '진짜 파일명'을 추출 (페이지 타이틀 사용 X).</summary>
public static string DeriveName(Uri url)
{
var segs = url.Segments
.Select(s => Uri.UnescapeDataString(s).Trim('/'))
.Where(s => s.Length > 0)
.ToArray();
for (int i = segs.Length - 1; i >= 0; i--)
{
var withoutExt = Path.GetFileNameWithoutExtension(segs[i]);
if (string.IsNullOrWhiteSpace(withoutExt)) continue;
if (GenericNames.Contains(withoutExt)) continue; // index.m3u8 등은 건너뜀
return Sanitize(withoutExt);
}
return Sanitize(url.Host); // fallback: 호스트명
}
}

View file

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

6
VideoDownloader.slnx Normal file
View file

@ -0,0 +1,6 @@
<Solution>
<Project Path="VideoDownloader.App/VideoDownloader.App.csproj" />
<Project Path="VideoDownloader.Avalonia/VideoDownloader.Avalonia.csproj" />
<Project Path="VideoDownloader.Cli/VideoDownloader.Cli.csproj" />
<Project Path="VideoDownloader.Core/VideoDownloader.Core.csproj" />
</Solution>

60
build/installer.iss Normal file
View file

@ -0,0 +1,60 @@
; VideoDownloader Windows 설치자 (Inno Setup)
; publish.ps1 이 /DARCH=x64 또는 /DARCH=arm64 로 2회 컴파일 → out/VideoDownloader-Setup-<arch>.exe
#define APP_NAME "VideoDownloader"
#define APP_PUBLISHER "VideoDownloader"
#define APP_EXE "VideoDownloader.exe"
#ifndef APP_VERSION
#define APP_VERSION "1.0.0"
#endif
#ifndef ARCH
#error ARCH must be defined (x64 or arm64) via /DARCH=
#endif
[Setup]
AppName={#APP_NAME}
AppVersion={#APP_VERSION}
AppPublisher={#APP_PUBLISHER}
UninstallDisplayIcon={app}\{#APP_EXE}
DefaultDirName={autopf}\{#APP_NAME}
DefaultGroupName={#APP_NAME}
DisableProgramGroupPage=yes
; per-user 설치 → {autopf} = %LocalAppData%\Programs, UAC 없음
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
OutputDir=..\out
WizardStyle=modern
Compression=lzma2
SolidCompression=yes
ArchitecturesInstallIn64BitMode=x64compatible
#if ARCH == "arm64"
; arm64 전용(네이티브). x64 머신에선 거부.
ArchitecturesAllowed=arm64
AppId={{2E0ADFC1-2072-4457-8C50-ECFCEDECAD73}
OutputBaseFilename=VideoDownloader-Setup-arm64
#else
; x64 + arm64(x64 에뮬레이션 폴백) 어디서든 설치 가능한 범용 설치자.
ArchitecturesAllowed=x64compatible
AppId={{65393C7D-17DC-4B9F-9D0B-F9911092C449}
OutputBaseFilename=VideoDownloader-Setup-x64
#endif
[Languages]
Name: "korean"; MessagesFile: "compiler:Languages\Korean.isl"
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
; self-contained publish 결과 전체를 앱 디렉터리로 복사
Source: "{#PUBLISH_DIR}\*"; DestDir: "{app}"; Flags: recursesubdirs createallsubdirs ignoreversion
[Icons]
Name: "{group}\{#APP_NAME}"; Filename: "{app}\{#APP_EXE}"
Name: "{group}\{#APP_NAME} 제거"; Filename: "{uninstallexe}"
Name: "{commondesktop}\{#APP_NAME}"; Filename: "{app}\{#APP_EXE}"; Tasks: desktopicon
[Run]
Filename: "{app}\{#APP_EXE}"; Description: "{cm:LaunchProgram,{#APP_NAME}}"; Flags: nowait postinstall skipifsilent

52
build/publish.ps1 Normal file
View file

@ -0,0 +1,52 @@
# VideoDownloader 다중 아키텍처 설치자 빌드
# 사용: ./build/publish.ps1 [-Config Release] [-Version 1.0.0]
# 결과: out/VideoDownloader-Setup-x64.exe, out/VideoDownloader-Setup-arm64.exe
param(
[string]$Config = "Release",
[string]$Version = "1.0.0",
[string[]]$Archs = @("x64", "arm64")
)
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot # 레포 루트
$app = Join-Path $root "VideoDownloader.App\VideoDownloader.App.csproj"
$iss = Join-Path $PSScriptRoot "installer.iss"
$outDir = Join-Path $root "out"
# --- ISCC(=Inno Setup 컴파일러) 위치 해결 ---
$iscc = $null
foreach ($cand in @(
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"$env:LOCALAPPDATA\Programs\Inno Setup 6\ISCC.exe",
"$env:LOCALAPPDATA\Programs\Inno Setup 6.7\ISCC.exe"
)) {
if ($cand -and (Test-Path $cand)) { $iscc = $cand; break }
}
if (-not $iscc) { $iscc = (Get-Command ISCC.exe -ErrorAction SilentlyContinue).Source }
if (-not $iscc) {
throw "Inno Setup(ISCC) 를 찾을 수 없습니다. 설치: winget install --id JRSoftware.InnoSetup -e"
}
Write-Host "ISCC: $iscc" -ForegroundColor DarkGray
New-Item -ItemType Directory -Force -Path $outDir | Out-Null
foreach ($arch in $Archs) {
$rid = "win-$arch"
$pub = Join-Path $root "publish\$arch"
Write-Host ""
Write-Host "==== [$arch] publish ($rid, self-contained) ====" -ForegroundColor Cyan
if (Test-Path $pub) { Remove-Item $pub -Recurse -Force }
# WPF 멀티플랫폼: Platform 축을 RID 와 일치시켜 빌드.
dotnet publish $app -c $Config -r $rid -p:Platform=$arch --self-contained -o $pub
if ($LASTEXITCODE -ne 0) { throw "publish 실패: $rid (exit $LASTEXITCODE). arm64 는 yt-dlp/WebView2 런타임 관련 처음 빌드 시 이슈가 있을 수 있습니다." }
Write-Host "==== [$arch] Inno Setup 컴파일 ====" -ForegroundColor Cyan
& $iscc /Q "/DAPP_VERSION=$Version" "/DARCH=$arch" "/DPUBLISH_DIR=$pub" $iss
if ($LASTEXITCODE -ne 0) { throw "ISCC 실패: $arch (exit $LASTEXITCODE)" }
Write-Host "완료: out\VideoDownloader-Setup-$arch.exe" -ForegroundColor Green
}
Write-Host ""
Write-Host "설치자 생성 완료:" -ForegroundColor Green
Get-ChildItem (Join-Path $outDir "VideoDownloader-Setup-*.exe") |
ForEach-Object { Write-Host (" {0,-38} {1:N1} MB" -f $_.Name, ($_.Length / 1MB)) }

477
downloader.py Normal file
View file

@ -0,0 +1,477 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
HLS / MP4 비디오 다운로더 (maccms 계열 스트리밍 사이트 대응)
==========================================================
- 페이지 URL 입력 시 영상 주소(m3u8/mp4) 자동 추출 (maccms player_aaaa, 정규식, iframe)
- HLS 완전 지원: 마스터 재생목록(최고 화질 자동 선택), AES-128 복호화, fMP4(#EXT-X-MAP) 초기화 세그먼트
- 세그먼트 병렬 다운로드 + 이어받기(중단 후 재실행 시 받은 분할 건너뜀)
- ffmpeg 자동 리먹스(TS/fMP4 -> MP4)
- Referer 등 커스텀 헤더 지원 (CDN 토큰 인증 대응)
의존성: requests (필수) / pycryptodome (AES 암호화 영상, 옵션) / tqdm (진행률, 옵션) / ffmpeg (리먹스, 권장)
"""
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import urljoin, urlparse
import requests
# Windows 콘솔 한글 깨짐 방지
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except Exception:
pass
DEFAULT_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
DEFAULT_HEADERS = {
"User-Agent": DEFAULT_UA,
"Accept": "*/*",
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
}
# m3u8 속성 파싱: KEY="VAL",KEY2=VAL
_ATTR_RE = re.compile(r'([A-Z0-9\-]+)=("([^"]*)"|([^,]*))')
# 페이지에서 영상 주소 추출용 정규식
_PLAYER_AAAA_RE = re.compile(r"player_aaaa\s*=\s*(\{.*?\})\s*[;<]", re.S)
_PLAYER_DATA_RE = re.compile(r"player_data\s*=\s*(\{.*?\})\s*[;<]", re.S)
_M3U8_RE = re.compile(r"https?://[^\s\"'<>]*\.m3u8[^\s\"'<>]*", re.I)
_MP4_RE = re.compile(r"https?://[^\s\"'<>]*\.mp4[^\s\"'<>]*", re.I)
_IFRAME_RE = re.compile(r'<iframe[^>]+src=["\']([^"\']+)["\']', re.I)
def parse_attrs(s):
d = {}
for m in _ATTR_RE.finditer(s):
d[m.group(1)] = m.group(3) if m.group(3) is not None else m.group(4)
return d
def parse_m3u8(text, base_url):
"""m3u8 재생목록 파싱. 마스터(변환재생목록)와 미디어 재생목록을 모두 처리."""
result = {"variants": [], "segments": [], "init": None, "is_master": False}
pending_variant = None # 직전 #EXT-X-STREAM-INF 속성
cur_key = None # 현재 유효한 #EXT-X-KEY (세그먼트별로 참조 복사)
seq = 0
idx = 0
saw_seq = False
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
if line.startswith("#EXT-X-STREAM-INF:"):
pending_variant = parse_attrs(line[len("#EXT-X-STREAM-INF:"):])
continue
if line.startswith("#EXT-X-MEDIA-SEQUENCE:"):
try:
seq = int(line.split(":", 1)[1].strip())
saw_seq = True
except ValueError:
pass
continue
if line.startswith("#EXT-X-KEY:"):
a = parse_attrs(line[len("#EXT-X-KEY:"):])
method = a.get("METHOD", "NONE").upper()
if method == "AES-128":
uri = a.get("URI")
if uri:
uri = urljoin(base_url, uri) # 재생목록 기준 절대경로화
cur_key = {"uri": uri, "iv": a.get("IV")}
else:
cur_key = None
continue
if line.startswith("#EXT-X-MAP:"):
a = parse_attrs(line[len("#EXT-X-MAP:"):])
if a.get("URI"):
result["init"] = urljoin(base_url, a["URI"]) # fMP4 초기화 세그먼트
continue
if line.startswith("#"):
continue
# URL 라인
abs_url = urljoin(base_url, line)
if pending_variant is not None:
bw = 0
try:
bw = int(pending_variant.get("BANDWIDTH", "0"))
except ValueError:
pass
result["variants"].append(
{
"url": abs_url,
"bandwidth": bw,
"res": pending_variant.get("RESOLUTION", ""),
"codecs": pending_variant.get("CODECS", ""),
}
)
pending_variant = None
result["is_master"] = True
else:
result["segments"].append(
{"url": abs_url, "index": idx, "seq": seq, "key": cur_key}
)
idx += 1
seq += 1
return result
# --------------------------------------------------------------------------- #
# 진행률 표시 (tqdm이 있으면 사용, 없으면 단순 폴백)
# --------------------------------------------------------------------------- #
class _SimpleProgress:
def __init__(self, total=None, desc="", unit=""):
self.total = total
self.n = 0
self.desc = desc
self.unit = unit
self._last_pct = -1
def update(self, n=1):
self.n += n
if self.total:
pct = int(self.n * 100 / self.total)
if pct != self._last_pct and pct % 5 == 0:
self._last_pct = pct
if self.unit == "B":
sys.stdout.write(
f"\r{self.desc}: {pct}% "
f"({self.n/1048576:.1f}/{self.total/1048576:.1f} MB)"
)
else:
sys.stdout.write(f"\r{self.desc}: {pct}% ({self.n}/{self.total})")
sys.stdout.flush()
def close(self):
sys.stdout.write("\n")
sys.stdout.flush()
def make_progress(total=None, **kw):
try:
from tqdm import tqdm
return tqdm(total=total, **kw)
except Exception:
return _SimpleProgress(total=total, desc=kw.get("desc", ""), unit=kw.get("unit", ""))
# --------------------------------------------------------------------------- #
# 다운로더 본체
# --------------------------------------------------------------------------- #
class Downloader:
def __init__(self, output, headers=None, concurrency=8, retries=6, timeout=30):
self.session = requests.Session()
self.session.headers.update(DEFAULT_HEADERS)
for k, v in (headers or {}).items():
self.session.headers[k] = v
self.concurrency = max(1, concurrency)
self.retries = retries
self.timeout = timeout
self.output = output
self.tmp = output + ".parts"
os.makedirs(self.tmp, exist_ok=True)
self._key_cache = {}
# ---- 공통 HTTP ---- #
def _get_text(self, url):
r = self.session.get(url, timeout=self.timeout)
r.raise_for_status()
return r.text
def _download_file(self, url, path):
tmp = path + ".tmp"
with self.session.get(url, timeout=self.timeout, stream=True) as r:
r.raise_for_status()
with open(tmp, "wb") as f:
for chunk in r.iter_content(65536):
if chunk:
f.write(chunk)
os.replace(tmp, path)
# ---- 진입: URL 종류 판별 ---- #
def run(self, url):
path = urlparse(url).path.lower()
if ".m3u8" in path:
return self.run_hls(url)
if path.endswith(".mp4"):
return self.run_direct(url)
return self.run_page(url)
# ---- 페이지에서 영상 주소 추출 ---- #
def run_page(self, page_url):
print(f"페이지 분석 중: {page_url}")
self.session.headers.setdefault("Referer", page_url)
html = self._get_text(page_url)
cands = [] # (kind, url)
def scan(text):
for rx, kind in ((_PLAYER_AAAA_RE, "player_aaaa"), (_PLAYER_DATA_RE, "player_data")):
for m in rx.finditer(text):
try:
obj = json.loads(m.group(1))
except ValueError:
continue
for field in ("url", "parse", "parse2"):
u = obj.get(field)
if isinstance(u, str) and u.startswith("http"):
cands.append((f"{kind}.{field}", u))
for u in _M3U8_RE.findall(text):
cands.append(("m3u8", u))
for u in _MP4_RE.findall(text):
cands.append(("mp4", u))
scan(html)
# iframe 내부 플레이어 페이지도 1단계까지 탐색
for src in _IFRAME_RE.findall(html):
abs_src = urljoin(page_url, src)
if "http" not in abs_src:
continue
try:
scan(self._get_text(abs_src))
except Exception:
pass
# 중복 제거(순서 유지) 후 m3u8 우선
seen, uniq = set(), []
for kind, u in cands:
if u in seen:
continue
seen.add(u)
uniq.append((kind, u))
if not uniq:
raise RuntimeError(
"페이지에서 영상 주소를 찾지 못했습니다. README의 "
"'m3u8 주소 직접 구하기' 방법으로 주소를 얻어 .m3u8 주소를 직접 넘겨주세요."
)
print("발견된 영상 주소 후보:")
for i, (kind, u) in enumerate(uniq):
print(f" [{i}] ({kind}) {u}")
chosen = next((u for _, u in uniq if ".m3u8" in u.lower()), None)
if chosen is None:
chosen = next((u for _, u in uniq if ".mp4" in u.lower()), uniq[0][1])
print(f"\n선택: {chosen}\n")
if ".m3u8" in chosen.lower():
return self.run_hls(chosen)
return self.run_direct(chosen)
# ---- HLS 다운로드 ---- #
def run_hls(self, m3u8_url):
print(f"HLS 재생목록 로드: {m3u8_url}")
parsed = parse_m3u8(self._get_text(m3u8_url), m3u8_url)
# 마스터 재생목록이면 최고 화질 변환 선택 후 미디어 재생목록 로드
while parsed["is_master"]:
best = max(parsed["variants"], key=lambda v: v["bandwidth"])
label = best["res"] or f"{best['bandwidth']}bps"
print(f"마스터 재생목록 -> 최고 화질 선택: {label}")
parsed = parse_m3u8(self._get_text(best["url"]), best["url"])
segs = parsed["segments"]
if not segs:
raise RuntimeError("재생목록에 다운로드할 세그먼트가 없습니다.")
encrypted = any(s["key"] for s in segs)
print(f"세그먼트 수: {len(segs)} | 암호화: {'AES-128' if encrypted else '없음'}")
# fMP4 초기화 세그먼트
init_path = None
if parsed["init"]:
init_path = os.path.join(self.tmp, "init.fmp4")
print("fMP4 초기화 세그먼트 다운로드 ...")
self._download_file(parsed["init"], init_path)
self._download_segments(segs)
merged = self._merge(segs, init_path)
final = self._remux(merged, init_path is not None)
return final
def _seg_path(self, seg):
return os.path.join(self.tmp, f"{seg['index']:06d}.seg")
def _download_segments(self, segs):
todo = [s for s in segs if not (os.path.exists(self._seg_path(s)) and os.path.getsize(self._seg_path(s)) > 0)]
skipped = len(segs) - len(todo)
print(f"다운로드: {len(todo)}개 (이미 받은 {skipped}개 건너뜀), 동시 {self.concurrency}연결")
prog = make_progress(total=len(segs), initial=skipped, desc="세그먼트", unit="seg")
failures = []
def work(seg):
for attempt in range(1, self.retries + 1):
try:
self._download_file(seg["url"], self._seg_path(seg))
return None
except Exception as e:
if attempt == self.retries:
return (seg, str(e))
time.sleep(min(2 ** attempt, 10))
return None
with ThreadPoolExecutor(max_workers=self.concurrency) as ex:
futures = [ex.submit(work, s) for s in todo]
for fut in as_completed(futures):
err = fut.result()
if err:
failures.append(err)
prog.update(1)
prog.close()
if failures:
sample = "; ".join(f"{s['index']}({m})" for s, m in failures[:3])
raise RuntimeError(f"{len(failures)}개 세그먼트 실패(재시도 후): {sample} ...")
@staticmethod
def _aes():
try:
from Crypto.Cipher import AES
return AES
except ImportError:
raise RuntimeError("암호화 영상입니다. 'pip install pycryptodome' 설치 후 재시도하세요.")
def _decrypt(self, data, seg):
key = seg["key"]
if not key:
return data
if key["uri"] not in self._key_cache:
r = self.session.get(key["uri"], timeout=self.timeout)
r.raise_for_status()
self._key_cache[key["uri"]] = r.content # 보통 16바이트
keybytes = self._key_cache[key["uri"]]
if key["iv"]:
ivb = bytes.fromhex(key["iv"].replace("0x", "").replace(" ", ""))
else:
ivb = seg["seq"].to_bytes(16, "big") # IV 미지정 시 시퀀스 번호
cipher = self._aes().new(keybytes, self._aes().MODE_CBC, ivb)
return cipher.decrypt(data)
def _merge(self, segs, init_path):
is_fmp4 = init_path is not None
ext = ".mp4" if is_fmp4 else ".ts"
merged = os.path.abspath(self.output + ".merged" + ext)
print("세그먼트 병합 중 ...")
with open(merged, "wb") as out:
if init_path and os.path.exists(init_path):
with open(init_path, "rb") as f:
out.write(f.read())
for seg in sorted(segs, key=lambda x: x["index"]):
with open(self._seg_path(seg), "rb") as f:
out.write(self._decrypt(f.read(), seg))
return merged
def _remux(self, merged, is_fmp4):
base, ext = os.path.splitext(self.output)
if ext.lower() in (".ts", ".m3u8", ".m4s", ".fmp4"):
ext = ""
ffmpeg = shutil.which("ffmpeg")
if ffmpeg:
final = (base or "video") + ".mp4"
if os.path.abspath(final) == os.path.abspath(merged):
final = (base or "video") + ".out.mp4"
print(f"ffmpeg 리먹스(-c copy) -> {os.path.basename(final)}")
ret = subprocess.run(
[ffmpeg, "-y", "-hide_banner", "-loglevel", "error", "-i", merged, "-c", "copy", final]
)
if ret.returncode == 0 and os.path.exists(final) and os.path.getsize(final) > 0:
os.remove(merged)
return final
print("경고: ffmpeg 리먹스 실패, 병합본을 그대로 유지합니다.", file=sys.stderr)
return merged
# ffmpeg 없음: 확장자만 맞춰 이동
final = (base or "video") + (".mp4" if is_fmp4 else ".ts")
os.replace(merged, final)
print("안내: ffmpeg가 없어 리먹스를 생략했습니다. MP4 변환을 권장합니다.", file=sys.stderr)
return final
# ---- 직접 MP4 다운로드 ---- #
def run_direct(self, url):
base, ext = os.path.splitext(self.output)
final = self.output if ext.lower() in (".mp4", ".mkv", ".ts") else (base or "video") + ".mp4"
print(f"직접 다운로드: {url}")
with self.session.get(url, timeout=self.timeout, stream=True) as r:
r.raise_for_status()
total = int(r.headers.get("Content-Length", 0)) or None
prog = make_progress(total=total, desc="다운로드", unit="B", unit_scale=True)
tmp = final + ".tmp"
with open(tmp, "wb") as f:
for chunk in r.iter_content(65536):
if chunk:
f.write(chunk)
prog.update(len(chunk))
prog.close()
os.replace(tmp, final)
return final
def main():
ap = argparse.ArgumentParser(
description="HLS/MP4 비디오 다운로더 (maccms 계열 사이트 대응)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"예제:\n"
' python downloader.py "https://site/vod/play/id/123/sid/1/nid/1.html" -o movie\n'
' python downloader.py "https://cdn.example.com/v/index.m3u8" -o movie --referer "https://site/"\n'
' python downloader.py "https://cdn.example.com/v/index.m3u8" --referer "https://site/" -H "Origin: https://site"\n'
),
)
ap.add_argument("url", help="재생페이지 URL 또는 .m3u8 / .mp4 직접 주소")
ap.add_argument("-o", "--output", default="video", help="출력 파일명(확장자 생략 가능, 기본 video)")
ap.add_argument("--referer", help="Referer 헤더 (CDN 토큰 인증에 필요한 경우)")
ap.add_argument("-H", "--header", action="append", default=[], metavar='"Key: Value"', help="추가 헤더 (여러개 가능)")
ap.add_argument("-c", "--concurrency", type=int, default=8, help="동시 다운로드 연결 수 (기본 8)")
ap.add_argument("--user-agent", default=None, help="User-Agent 재정의")
ap.add_argument("--keep-parts", action="store_true", help="병합 후 세그먼트 임시파일 유지(디버그용)")
args = ap.parse_args()
headers = {}
for h in args.header:
if ":" in h:
k, v = h.split(":", 1)
headers[k.strip()] = v.strip()
if args.referer:
headers["Referer"] = args.referer
if args.user_agent:
headers["User-Agent"] = args.user_agent
dl = Downloader(args.output, headers, concurrency=args.concurrency)
try:
final = dl.run(args.url)
except KeyboardInterrupt:
print("\n중단됨. 받은 세그먼트는 유지되어 재실행 시 이어받기됩니다.")
sys.exit(130)
except Exception as e:
print(f"\n오류: {e}", file=sys.stderr)
sys.exit(1)
print(f"\n완료: {final}")
if not args.keep_parts:
shutil.rmtree(dl.tmp, ignore_errors=True)
if __name__ == "__main__":
main()

5
requirements.txt Normal file
View file

@ -0,0 +1,5 @@
requests>=2.28
# 아래는 옵션: 없으면 자동 폴백 또는 해당 기능만 비활성
pycryptodome>=3.18 # AES-128 암호화 영상 복호화용
tqdm>=4.60 # 진행륕 바 표시
# ffmpeg은 시스템에 설치(PATH)되어 있으면 자동 사용 — TS/fMP4 -> MP4 리먹스