commit 21b7c4d8bdbb833b3d7e8519e03ffcfca3c95054 Author: Yun Chan Date: Sat Aug 15 11:59:25 2026 +0900 Baseline: WPF/Avalonia/Cli/Core + plans (BROWSER_FEATURES_PLAN v2, MOBILE_PLAN v1) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bc8470 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +obj/ +out/ +publish/ +*.user +.vs/ +*.binlog +TestResults/ +app.log +requests.log diff --git a/BACKLOG.md b/BACKLOG.md new file mode 100644 index 0000000..a600d32 --- /dev/null +++ b/BACKLOG.md @@ -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 ` 로 위임. 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 ` 가 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** — `x64;arm64`, `win-x64;win-arm64`, `SelfContained=true`(폴더, single-file/trim 아님 — WPF+트리밍 위험 회피). `PlatformTarget` 하드코딩 제거(RID에서 파생). +- [x] **`build/publish.ps1`** — x64/arm64 루프: `dotnet publish -c Release -r -p:Platform= --self-contained` 후 ISCC(`/DARCH=`)로 `out/VideoDownloader-Setup-.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` 권장. diff --git a/BROWSER_FEATURES_PLAN.md b/BROWSER_FEATURES_PLAN.md new file mode 100644 index 0000000..03b5264 --- /dev/null +++ b/BROWSER_FEATURES_PLAN.md @@ -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개 조사팀 상세 보고서는 세션 에이전트 결과로 보존됨. diff --git a/MOBILE_PLAN.md b/MOBILE_PLAN.md new file mode 100644 index 0000000..2420894 --- /dev/null +++ b/MOBILE_PLAN.md @@ -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://:/#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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..48997f2 --- /dev/null +++ b/README.md @@ -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`) diff --git a/VideoDownloader.App/App.xaml b/VideoDownloader.App/App.xaml new file mode 100644 index 0000000..e47accf --- /dev/null +++ b/VideoDownloader.App/App.xaml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/VideoDownloader.App/App.xaml.cs b/VideoDownloader.App/App.xaml.cs new file mode 100644 index 0000000..0ffb814 --- /dev/null +++ b/VideoDownloader.App/App.xaml.cs @@ -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(); + } + + /// 테마 색(dark/light) + Shared(스타일) 리소스사전을 App.Resources 에 주입. + /// 주의: 반드시 색(brush) 사전을 스타일 사전보다 먼저 로드해야 Shared 의 템플릿이 + /// {StaticResource GlassBg} 등을 해석할 수 있다. + 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) }); + } + + /// 현재 적용된 테마가 라이트인지 (DWM 타이틀바 색 연동용). + 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 { } + } +} diff --git a/VideoDownloader.App/AssemblyInfo.cs b/VideoDownloader.App/AssemblyInfo.cs new file mode 100644 index 0000000..cc29e7f --- /dev/null +++ b/VideoDownloader.App/AssemblyInfo.cs @@ -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) +)] diff --git a/VideoDownloader.App/Browser/BrowserModels.cs b/VideoDownloader.App/Browser/BrowserModels.cs new file mode 100644 index 0000000..b513cd4 --- /dev/null +++ b/VideoDownloader.App/Browser/BrowserModels.cs @@ -0,0 +1,5 @@ +namespace VideoDownloader.App.Browser; + +public record Bookmark(string Title, string Url); + +public record HistoryEntry(string Title, string Url, DateTime VisitedAt); diff --git a/VideoDownloader.App/Browser/BrowserStore.cs b/VideoDownloader.App/Browser/BrowserStore.cs new file mode 100644 index 0000000..37bc140 --- /dev/null +++ b/VideoDownloader.App/Browser/BrowserStore.cs @@ -0,0 +1,68 @@ +using System.Text.Json; + +namespace VideoDownloader.App.Browser; + +/// 즐겨찾기/히스토리 JSON 영속화 (%AppData%/VideoDownloader). +/// #5 저장/로드 실패를 조용 삼킴하지 않고 browserstore.log 에 기록하며, +/// 손상 파일은 백업 후 빈 목록을 반환(다음 저장이 원본을 덮어쓰는 데이터 손실 방지). +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 LoadBookmarks() + { + try { if (File.Exists(BookmarksPath)) return JsonSerializer.Deserialize>(File.ReadAllText(BookmarksPath)) ?? new(); } + catch (Exception e) { LogLoadFail(BookmarksPath, e); } + return new(); + } + + public static void SaveBookmarks(List bookmarks) + { + try { Directory.CreateDirectory(Dir); File.WriteAllText(BookmarksPath, JsonSerializer.Serialize(bookmarks, JsonOpts)); } + catch (Exception e) { LogFail("저장", BookmarksPath, e); } // 디스크 가득/권한/잠금 → UI 스레드 전파 대신 로그 + } + + public static List LoadHistory() + { + try { if (File.Exists(HistoryPath)) return JsonSerializer.Deserialize>(File.ReadAllText(HistoryPath)) ?? new(); } + catch (Exception e) { LogLoadFail(HistoryPath, e); } + return new(); + } + + public static void SaveHistory(List history) + { + try { Directory.CreateDirectory(Dir); File.WriteAllText(HistoryPath, JsonSerializer.Serialize(history, JsonOpts)); } + catch (Exception e) { LogFail("저장", HistoryPath, e); } + } + + /// 로드 실패(손상/권한/잠금) 시 원본을 .corrupt 백업한 뒤 로그. + /// 빈 목록을 반환하더라도 원본이 남아 다음 저장이 사용자 데이터를 덮어쓰지 않게 한다. + 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 { /* 로그마저 실패하면 조용히 */ } + } +} diff --git a/VideoDownloader.App/Converters.cs b/VideoDownloader.App/Converters.cs new file mode 100644 index 0000000..56548d4 --- /dev/null +++ b/VideoDownloader.App/Converters.cs @@ -0,0 +1,15 @@ +using System.Globalization; +using System.Windows; +using System.Windows.Data; + +namespace VideoDownloader.App; + +/// 컬렉션 카운트 → 빈 상태 표시용 (0일 때 Visible) +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(); +} diff --git a/VideoDownloader.App/Core/ConfigStore.cs b/VideoDownloader.App/Core/ConfigStore.cs new file mode 100644 index 0000000..f5f0ec3 --- /dev/null +++ b/VideoDownloader.App/Core/ConfigStore.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using VideoDownloader.Core.Config; + +namespace VideoDownloader.App.Core; + +/// %AppData%/VideoDownloader/config.json 으로 설정 영속화 +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(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 { /* 저장 실패는 무시 */ } + } +} diff --git a/VideoDownloader.App/Core/DownloadManager.cs b/VideoDownloader.App/Core/DownloadManager.cs new file mode 100644 index 0000000..8ccf3f3 --- /dev/null +++ b/VideoDownloader.App/Core/DownloadManager.cs @@ -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; + +/// 감지 → (HLS: 마스터해석) → 목록(대기) 등록. 다운로드는 사용자가 선택 실행. +public class DownloadManager +{ + private readonly AppConfig _config; + private readonly Func>> _getCookies; + private readonly Action _log; + + private readonly object _itemsLock = new(); + private readonly HashSet _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 Items { get; } + + public DownloadManager(AppConfig config, Func>> getCookies, Action log) + { + _config = config; + _getCookies = getCookies; + _log = log; + _cap = Math.Max(1, config.MaxConcurrentDownloads); + _slot = new SemaphoreSlim(_cap); + Items = new ObservableCollection(); + 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 → 공유 핸들러는 유지 + } + } + + /// 선택한 항목 재시도 (오류/취소/완료 포함). 진행 중은 무시. + 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); + } + + /// #1 다운로드 취소 + public void Cancel(DownloadItemViewModel item) + { + try { item.Cts?.Cancel(); } + catch { } + } + + /// 소셜 카드 제목 갱신(페이지 타이틀이 나중에 확정될 때). 아직 시작 전이면 출력 파일명도 갱신. + 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(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(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(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(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 { } + } + } + + /// 카드 "화질 불러오기" 버튼용: yt-dlp -J 로 실제 포맷을 조회해 드롭다운을 교체. + 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 { } + } + } + + /// WebView2 쿠키를 Netscape 파일로 내보냄. UseCookiesForSocial 이고 쿠키가 있을 때만. + private async Task 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 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; + } +} diff --git a/VideoDownloader.App/Detection/DetectionContext.cs b/VideoDownloader.App/Detection/DetectionContext.cs new file mode 100644 index 0000000..bea8984 --- /dev/null +++ b/VideoDownloader.App/Detection/DetectionContext.cs @@ -0,0 +1,6 @@ +namespace VideoDownloader.App.Detection; + +public enum MediaKind { Hls, Dash, Direct, Social } + +/// 감지된 미디어. HLS(m3u8) / DASH(.mpd) / 직링크(Direct mp4/webm/...) / Social(yt-dlp). +public record DetectionContext(string Url, string PageUrl, MediaKind Kind, string? ContentType = null, string? Title = null, string? Tag = null); diff --git a/VideoDownloader.App/Detection/MediaDetector.cs b/VideoDownloader.App/Detection/MediaDetector.cs new file mode 100644 index 0000000..f17539c --- /dev/null +++ b/VideoDownloader.App/Detection/MediaDetector.cs @@ -0,0 +1,81 @@ +using VideoDownloader.Core.Config; + +namespace VideoDownloader.App.Detection; + +/// 요청(URL + content-type + 길이)과 설정으로 미디어 종류를 판정. +public static class MediaDetector +{ + /// HLS 또는 Direct, 감지 대상이 아니면 null + 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); + + /// 형식 키 반환: mp4/webm/mkv/m4v/mov/avi/audio (미매칭 null) + 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; + } + + /// 감지 후보(사람에게 보여줄 만한) 판정 - DASH/MSE/세그먼트 등 + 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"); + + /// #15 본문 선두 magic byte 로 실제 포맷 판별 (위장 image/* 등 검증용). + /// 이미지 포맷(GIF/PNG/JPEG/WebP)은 null 반환 — 위장 트래킹 픽셀이 미디어로 오탐되는 것 방지. + /// (GIF 'G'=0x47 가 MPEG-TS sync byte 와 겹쳐 ts 로 오탐되던 문제 수정) + public static string? ProbeFormat(ReadOnlySpan 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; + } +} diff --git a/VideoDownloader.App/Detection/SocialSiteDetector.cs b/VideoDownloader.App/Detection/SocialSiteDetector.cs new file mode 100644 index 0000000..e475343 --- /dev/null +++ b/VideoDownloader.App/Detection/SocialSiteDetector.cs @@ -0,0 +1,136 @@ +using System.Text.RegularExpressions; + +namespace VideoDownloader.App.Detection; + +/// +/// 소셜 영상 페이지(YouTube/Shorts/Instagram/X/TikTok/Facebook) 판별 + URL 정규화. +/// yt-dlp 로 처리 가능한 단일 영상 URL 인지 검사하고, 같은 영상의 여러 URL 형태를 +/// 하나의 정규화된 키로 통일(중복 등록 방지). 홈/검색/채널 페이지는 거짓. +/// +public static class SocialSiteDetector +{ + private static readonly Regex YtId = new(@"^[A-Za-z0-9_-]{11}$", RegexOptions.Compiled); + + /// 소셜 영상 URL 이면 정규화된 URL 과 사이트 라벨을 반환. + 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// , /reels// , /p// , /tv// , /stories/// + 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; + } + + /// 쿼리스트링에서 단일 키 값 추출(? 제외 후 & 와 = 로 분해). + 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; + } + + /// scheme://host + 매칭된 경로(쿼리/트래킹 제거). host 에 www 보정. + private static string Normalize(string host, string path) + { + var h = host.StartsWith("www.") ? host : "www." + host; + return $"https://{h}{path}"; + } +} diff --git a/VideoDownloader.App/MainWindow.xaml b/VideoDownloader.App/MainWindow.xaml new file mode 100644 index 0000000..f0cdeb9 --- /dev/null +++ b/VideoDownloader.App/MainWindow.xaml @@ -0,0 +1,399 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +