Baseline: WPF/Avalonia/Cli/Core + plans (BROWSER_FEATURES_PLAN v2, MOBILE_PLAN v1)
This commit is contained in:
commit
21b7c4d8bd
60 changed files with 6581 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
bin/
|
||||
obj/
|
||||
out/
|
||||
publish/
|
||||
*.user
|
||||
.vs/
|
||||
*.binlog
|
||||
TestResults/
|
||||
app.log
|
||||
requests.log
|
||||
219
BACKLOG.md
Normal file
219
BACKLOG.md
Normal 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
262
BROWSER_FEATURES_PLAN.md
Normal 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
246
MOBILE_PLAN.md
Normal 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
71
README.md
Normal 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`)
|
||||
11
VideoDownloader.App/App.xaml
Normal file
11
VideoDownloader.App/App.xaml
Normal 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>
|
||||
53
VideoDownloader.App/App.xaml.cs
Normal file
53
VideoDownloader.App/App.xaml.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
10
VideoDownloader.App/AssemblyInfo.cs
Normal file
10
VideoDownloader.App/AssemblyInfo.cs
Normal 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)
|
||||
)]
|
||||
5
VideoDownloader.App/Browser/BrowserModels.cs
Normal file
5
VideoDownloader.App/Browser/BrowserModels.cs
Normal 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);
|
||||
68
VideoDownloader.App/Browser/BrowserStore.cs
Normal file
68
VideoDownloader.App/Browser/BrowserStore.cs
Normal 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 { /* 로그마저 실패하면 조용히 */ }
|
||||
}
|
||||
}
|
||||
15
VideoDownloader.App/Converters.cs
Normal file
15
VideoDownloader.App/Converters.cs
Normal 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();
|
||||
}
|
||||
35
VideoDownloader.App/Core/ConfigStore.cs
Normal file
35
VideoDownloader.App/Core/ConfigStore.cs
Normal 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 { /* 저장 실패는 무시 */ }
|
||||
}
|
||||
}
|
||||
476
VideoDownloader.App/Core/DownloadManager.cs
Normal file
476
VideoDownloader.App/Core/DownloadManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
6
VideoDownloader.App/Detection/DetectionContext.cs
Normal file
6
VideoDownloader.App/Detection/DetectionContext.cs
Normal 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);
|
||||
81
VideoDownloader.App/Detection/MediaDetector.cs
Normal file
81
VideoDownloader.App/Detection/MediaDetector.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
136
VideoDownloader.App/Detection/SocialSiteDetector.cs
Normal file
136
VideoDownloader.App/Detection/SocialSiteDetector.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
399
VideoDownloader.App/MainWindow.xaml
Normal file
399
VideoDownloader.App/MainWindow.xaml
Normal 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="" 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="" 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="" ToolTip="뒤로 (Alt+←)" Margin="0,0,3,0" Click="NavBack_Click"/>
|
||||
<Button Name="NavForward" Style="{StaticResource IconButton}" Content="" ToolTip="앞으로 (Alt+→)" Margin="0,0,3,0" Click="NavForward_Click"/>
|
||||
<Button Name="NavRefresh" Style="{StaticResource IconButton}" Content="" ToolTip="새로고침 (F5)" Margin="0,0,3,0" Click="NavRefresh_Click"/>
|
||||
<Button Name="NavHome" Style="{StaticResource IconButton}" Content="" 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="" ToolTip="즐겨찾기" Margin="0,0,2,0" Click="ToggleBookmarks"/>
|
||||
<Button Name="HistoryBtn" Style="{StaticResource IconButton}" Content="" ToolTip="방문 기록" Margin="0,0,2,0" Click="ToggleHistory"/>
|
||||
<Button Style="{StaticResource IconButton}" Content="" ToolTip="저장 폴더 열기" Margin="0,0,2,0" Click="OpenFolder_Click"/>
|
||||
<Button Name="LinkBtn" Style="{StaticResource IconButton}" Content="" ToolTip="링크로 받기 (YouTube·Instagram·X)" Margin="0,0,2,0" Click="ToggleLink"/>
|
||||
<Button x:Name="PrivateBtn" Style="{StaticResource IconButton}" Content="" ToolTip="개인정보 보호 모드(InPrivate)" Margin="0,0,2,0" Click="TogglePrivate_Click"/>
|
||||
<Button Style="{StaticResource IconButton}" Content="" 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="" ToolTip="최소화" Click="Minimize_Click"/>
|
||||
<Button Name="MaxRestoreBtn" Style="{StaticResource CaptionButton}" Content="" ToolTip="최대화" Click="MaxRestore_Click"/>
|
||||
<Button Style="{StaticResource CloseButton}" Content="" 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=""/>
|
||||
<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="" 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>
|
||||
764
VideoDownloader.App/MainWindow.xaml.cs
Normal file
764
VideoDownloader.App/MainWindow.xaml.cs
Normal 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] + "…");
|
||||
}
|
||||
63
VideoDownloader.App/README.md
Normal file
63
VideoDownloader.App/README.md
Normal 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)
|
||||
- 오디오/자막 트랙 분리
|
||||
167
VideoDownloader.App/SettingsWindow.xaml
Normal file
167
VideoDownloader.App/SettingsWindow.xaml
Normal 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="" 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>
|
||||
165
VideoDownloader.App/SettingsWindow.xaml.cs
Normal file
165
VideoDownloader.App/SettingsWindow.xaml.cs
Normal 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);
|
||||
}
|
||||
35
VideoDownloader.App/Themes/Dark.xaml
Normal file
35
VideoDownloader.App/Themes/Dark.xaml
Normal 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>
|
||||
35
VideoDownloader.App/Themes/Light.xaml
Normal file
35
VideoDownloader.App/Themes/Light.xaml
Normal 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>
|
||||
418
VideoDownloader.App/Themes/Shared.xaml
Normal file
418
VideoDownloader.App/Themes/Shared.xaml
Normal 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="" 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>
|
||||
36
VideoDownloader.App/VideoDownloader.App.csproj
Normal file
36
VideoDownloader.App/VideoDownloader.App.csproj
Normal 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>
|
||||
58
VideoDownloader.App/ViewModels/DownloadItemViewModel.cs
Normal file
58
VideoDownloader.App/ViewModels/DownloadItemViewModel.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
10
VideoDownloader.Avalonia/App.axaml
Normal file
10
VideoDownloader.Avalonia/App.axaml
Normal 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>
|
||||
23
VideoDownloader.Avalonia/App.axaml.cs
Normal file
23
VideoDownloader.Avalonia/App.axaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
114
VideoDownloader.Avalonia/MainViewModel.cs
Normal file
114
VideoDownloader.Avalonia/MainViewModel.cs
Normal 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));
|
||||
}
|
||||
63
VideoDownloader.Avalonia/MainWindow.axaml
Normal file
63
VideoDownloader.Avalonia/MainWindow.axaml
Normal 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>
|
||||
19
VideoDownloader.Avalonia/MainWindow.axaml.cs
Normal file
19
VideoDownloader.Avalonia/MainWindow.axaml.cs
Normal 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);
|
||||
};
|
||||
}
|
||||
}
|
||||
24
VideoDownloader.Avalonia/Program.cs
Normal file
24
VideoDownloader.Avalonia/Program.cs
Normal 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();
|
||||
}
|
||||
16
VideoDownloader.Avalonia/RelayCommand.cs
Normal file
16
VideoDownloader.Avalonia/RelayCommand.cs
Normal 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);
|
||||
}
|
||||
28
VideoDownloader.Avalonia/VideoDownloader.Avalonia.csproj
Normal file
28
VideoDownloader.Avalonia/VideoDownloader.Avalonia.csproj
Normal 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>
|
||||
18
VideoDownloader.Avalonia/app.manifest
Normal file
18
VideoDownloader.Avalonia/app.manifest
Normal 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>
|
||||
93
VideoDownloader.Cli/Program.cs
Normal file
93
VideoDownloader.Cli/Program.cs
Normal 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;
|
||||
}
|
||||
14
VideoDownloader.Cli/VideoDownloader.Cli.csproj
Normal file
14
VideoDownloader.Cli/VideoDownloader.Cli.csproj
Normal 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>
|
||||
70
VideoDownloader.Core/Config/AppConfig.cs
Normal file
70
VideoDownloader.Core/Config/AppConfig.cs
Normal 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;
|
||||
}
|
||||
154
VideoDownloader.Core/Dash/DashDownloader.cs
Normal file
154
VideoDownloader.Core/Dash/DashDownloader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
20
VideoDownloader.Core/Dash/DashModels.cs
Normal file
20
VideoDownloader.Core/Dash/DashModels.cs
Normal 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; }
|
||||
}
|
||||
196
VideoDownloader.Core/Dash/DashParser.cs
Normal file
196
VideoDownloader.Core/Dash/DashParser.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
156
VideoDownloader.Core/Hls/DirectDownloader.cs
Normal file
156
VideoDownloader.Core/Hls/DirectDownloader.cs
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
261
VideoDownloader.Core/Hls/HlsDownloader.cs
Normal file
261
VideoDownloader.Core/Hls/HlsDownloader.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
101
VideoDownloader.Core/Hls/M3u8Parser.cs
Normal file
101
VideoDownloader.Core/Hls/M3u8Parser.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
8
VideoDownloader.Core/Models/DownloadProgress.cs
Normal file
8
VideoDownloader.Core/Models/DownloadProgress.cs
Normal 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);
|
||||
}
|
||||
21
VideoDownloader.Core/Models/PlaylistModels.cs
Normal file
21
VideoDownloader.Core/Models/PlaylistModels.cs
Normal 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 초기화 세그먼트)
|
||||
}
|
||||
51
VideoDownloader.Core/Social/CookieJarWriter.cs
Normal file
51
VideoDownloader.Core/Social/CookieJarWriter.cs
Normal 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', ' ');
|
||||
}
|
||||
4
VideoDownloader.Core/Social/CookieRecord.cs
Normal file
4
VideoDownloader.Core/Social/CookieRecord.cs
Normal 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);
|
||||
105
VideoDownloader.Core/Social/DenoRunner.cs
Normal file
105
VideoDownloader.Core/Social/DenoRunner.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
8
VideoDownloader.Core/Social/FormatOption.cs
Normal file
8
VideoDownloader.Core/Social/FormatOption.cs
Normal 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<=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");
|
||||
28
VideoDownloader.Core/Social/SocialPresets.cs
Normal file
28
VideoDownloader.Core/Social/SocialPresets.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
259
VideoDownloader.Core/Social/YtDlpDownloader.cs
Normal file
259
VideoDownloader.Core/Social/YtDlpDownloader.cs
Normal 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() ?? "" : "";
|
||||
}
|
||||
214
VideoDownloader.Core/Social/YtDlpRunner.cs
Normal file
214
VideoDownloader.Core/Social/YtDlpRunner.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
VideoDownloader.Core/Util/FileNameUtil.cs
Normal file
50
VideoDownloader.Core/Util/FileNameUtil.cs
Normal 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: 호스트명
|
||||
}
|
||||
}
|
||||
9
VideoDownloader.Core/VideoDownloader.Core.csproj
Normal file
9
VideoDownloader.Core/VideoDownloader.Core.csproj
Normal 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
6
VideoDownloader.slnx
Normal 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
60
build/installer.iss
Normal 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
52
build/publish.ps1
Normal 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
477
downloader.py
Normal 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
5
requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
requests>=2.28
|
||||
# 아래는 옵션: 없으면 자동 폴백 또는 해당 기능만 비활성
|
||||
pycryptodome>=3.18 # AES-128 암호화 영상 복호화용
|
||||
tqdm>=4.60 # 진행륕 바 표시
|
||||
# ffmpeg은 시스템에 설치(PATH)되어 있으면 자동 사용 — TS/fMP4 -> MP4 리먹스
|
||||
Loading…
Add table
Add a link
Reference in a new issue