Baseline: WPF/Avalonia/Cli/Core + plans (BROWSER_FEATURES_PLAN v2, MOBILE_PLAN v1)

This commit is contained in:
Yun Chan 2026-08-15 11:59:25 +09:00
commit 21b7c4d8bd
60 changed files with 6581 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,476 @@
using System.Collections.ObjectModel;
using System.Net;
using System.Net.Http;
using System.Windows.Data;
using Microsoft.Web.WebView2.Core;
using VideoDownloader.App.Detection;
using VideoDownloader.App.ViewModels;
using VideoDownloader.Core.Config;
using VideoDownloader.Core.Dash;
using VideoDownloader.Core.Hls;
using VideoDownloader.Core.Models;
using VideoDownloader.Core.Social;
using VideoDownloader.Core.Util;
namespace VideoDownloader.App.Core;
/// <summary>감지 → (HLS: 마스터해석) → 목록(대기) 등록. 다운로드는 사용자가 선택 실행.</summary>
public class DownloadManager
{
private readonly AppConfig _config;
private readonly Func<Uri, Task<List<CoreWebView2Cookie>>> _getCookies;
private readonly Action<string> _log;
private readonly object _itemsLock = new();
private readonly HashSet<string> _seenMedia = new();
private readonly object _seenLock = new();
private readonly string _reqLogPath;
private readonly object _fileLock = new();
// #5/#19 동시 다운로드(항목) 제한 — 런타임 cap 변경 지원(동적 게이트).
// 증가는 즉시 Release, 감소는 진행 중 항목이 완료될 때 슬롯을 채우지 않아 자연 수축.
private readonly SemaphoreSlim _slot;
private int _cap;
private int _held;
private readonly object _gateLock = new();
// #11 공유 커넥션 풀 — 호출마다 HttpClientHandler 를 new 하지 않아 소켓 고갈 방지.
// 쿠키는 컨테이너(프로세스 수준 누적·타사이트 누출) 대신 요청(다운로드) 단위 헤더로 스코프.
private static readonly SocketsHttpHandler SharedHandler = new()
{
UseCookies = false,
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
AllowAutoRedirect = true,
MaxConnectionsPerServer = 32,
};
public ObservableCollection<DownloadItemViewModel> Items { get; }
public DownloadManager(AppConfig config, Func<Uri, Task<List<CoreWebView2Cookie>>> getCookies, Action<string> log)
{
_config = config;
_getCookies = getCookies;
_log = log;
_cap = Math.Max(1, config.MaxConcurrentDownloads);
_slot = new SemaphoreSlim(_cap);
Items = new ObservableCollection<DownloadItemViewModel>();
BindingOperations.EnableCollectionSynchronization(Items, _itemsLock);
_reqLogPath = Path.Combine(config.OutputFolder, "requests.log");
try { Directory.CreateDirectory(config.OutputFolder); File.WriteAllText(_reqLogPath, ""); } catch { }
}
public void AppendAll(string line)
{
try
{
lock (_fileLock)
{
// #8 로테이션 (10MB 초과 시 .1 로 밀고 truncate)
try
{
var fi = new FileInfo(_reqLogPath);
if (fi.Exists && fi.Length > 10 * 1024 * 1024)
{
var bak = _reqLogPath + ".1";
if (File.Exists(bak)) File.Delete(bak);
File.Move(_reqLogPath, bak);
}
}
catch { }
File.AppendAllText(_reqLogPath, $"{DateTime.Now:HH:mm:ss} {line}{Environment.NewLine}");
}
}
catch { }
}
public void LogPanel(string line) => _log(line);
public void OnDetected(DetectionContext ctx) => _ = HandleAsync(ctx);
private async Task HandleAsync(DetectionContext ctx)
{
try
{
var detectedUrl = new Uri(ctx.Url);
_log($"[감지] {ctx.Kind} | {ctx.Url}");
// 즉시 중복체크 + 등록 → 목록에 바로 표시 (HLS 해석/쿠키 fetch 를 기다리지 않음).
// HLS 는 HlsDownloader 가 다운로드 시 마스터→미디어 를 직접 해석하므로 여기서 미리 해석할 필요 없음.
lock (_seenLock)
if (!_seenMedia.Add(ctx.Kind + "|" + detectedUrl)) { _log($"[중복] {detectedUrl}"); return; }
var title = ctx.Kind == MediaKind.Social && !string.IsNullOrWhiteSpace(ctx.Title)
? CleanSocialTitle(ctx.Title!)
: FileNameUtil.DeriveName(detectedUrl);
var outputBase = MakeUnique(Path.Combine(_config.OutputFolder, FileNameUtil.Sanitize(title)));
var fmt = ctx.Kind switch
{
MediaKind.Hls => "HLS",
MediaKind.Dash => "DASH",
MediaKind.Social => string.IsNullOrWhiteSpace(ctx.Tag) ? "SOCIAL" : ctx.Tag!,
_ => MediaDetector.MatchFormat(ctx.Url, ctx.ContentType ?? "")?.ToUpperInvariant() ?? "FILE",
};
var item = new DownloadItemViewModel(title)
{
Kind = ctx.Kind,
FormatTag = fmt,
MediaUrl = detectedUrl,
OutputBase = outputBase,
PageUrl = ctx.PageUrl,
ContentType = ctx.ContentType,
};
// 소셜 카드: 화질 프리셋 즉시 채우기(정밀 조회는 카드의 "불러오기" 버튼으로 별도)
if (ctx.Kind == MediaKind.Social)
{
foreach (var p in SocialPresets.All) item.Formats.Add(p);
item.SelectedFormat = SocialPresets.DefaultFor(_config.DefaultSocialQuality);
}
lock (_itemsLock) Items.Insert(0, item);
_log($"[등록] {title} | {detectedUrl}");
// HLS: 백그라운드에서 마스터를 한 번 받아 모든 변환(미디어 재생목록) URL 을 seen 으로 마킹 →
// 브라우저가 이후 해당 미디어 재생목록을 요청해도 중복 등록되지 않게.
if (ctx.Kind == MediaKind.Hls)
_ = MarkVariantsSeenAsync(detectedUrl, ctx.PageUrl);
}
catch (Exception e) { _log($"[오류] {ctx.Url} :: {e}"); }
}
private async Task MarkVariantsSeenAsync(Uri masterUrl, string? referer)
{
try
{
var http = await BuildHttpClientAsync(masterUrl, referer);
var parsed = M3u8Parser.Parse(await http.GetStringAsync(masterUrl), masterUrl);
if (parsed.Type == PlaylistType.Master)
{
lock (_seenLock)
foreach (var v in parsed.Variants)
_seenMedia.Add(MediaKind.Hls + "|" + v.Url);
}
}
catch { }
}
public async Task StartDownload(DownloadItemViewModel item)
{
if (item.Started) return;
item.Started = true;
_ = RunWithSlotAsync(item);
}
// #5 다운로드 슬롯(동시수) 제한 하에 실행. fire-and-forget 이되 예외 관찰.
// #19 설정 변경 시 동시 항목 수를 런타임에 반영.
public void UpdateMaxConcurrent(int newMax)
{
newMax = Math.Max(1, newMax);
lock (_gateLock)
{
int delta = newMax - _cap;
_cap = newMax;
if (delta > 0) _slot.Release(delta);
}
}
// #5 다운로드 슬롯(동시수) 제한 하에 실행. fire-and-forget 이되 예외 관찰.
private async Task RunWithSlotAsync(DownloadItemViewModel item)
{
await _slot.WaitAsync();
lock (_gateLock) _held++;
HttpClient? http = null;
try
{
http = await BuildHttpClientAsync(item.MediaUrl, item.PageUrl);
if (item.Kind == MediaKind.Hls) await DownloadHlsAsync(item, http);
else if (item.Kind == MediaKind.Dash) await DownloadDashAsync(item, http);
else if (item.Kind == MediaKind.Social) await DownloadSocialAsync(item);
else await DownloadDirectAsync(item, http);
}
catch (OperationCanceledException) { /* 다운로드 메서드가 상태 처리 */ }
catch (Exception e)
{
item.Status = "오류"; item.Error = e.Message;
_log($"오류({item.Title}): {e.Message}");
}
finally
{
bool refill;
lock (_gateLock) { _held--; refill = _held < _cap; }
if (refill) _slot.Release();
try { http?.Dispose(); } catch { } // disposeHandler=false → 공유 핸들러는 유지
}
}
/// <summary>선택한 항목 재시도 (오류/취소/완료 포함). 진행 중은 무시.</summary>
public async Task RetryDownload(DownloadItemViewModel item)
{
if (item.Status is "다운로드 중" or "병합 중" or "리먹스 중") return;
item.Started = false;
item.Progress = 0;
item.Segments = "";
item.Error = null;
await StartDownload(item);
}
/// <summary>#1 다운로드 취소</summary>
public void Cancel(DownloadItemViewModel item)
{
try { item.Cts?.Cancel(); }
catch { }
}
/// <summary>소셜 카드 제목 갱신(페이지 타이틀이 나중에 확정될 때). 아직 시작 전이면 출력 파일명도 갱신.</summary>
public void RenameSocial(Uri url, string title)
{
if (string.IsNullOrWhiteSpace(title)) return;
var clean = CleanSocialTitle(title);
lock (_itemsLock)
{
var it = Items.FirstOrDefault(i => i.Kind == MediaKind.Social && i.MediaUrl == url);
if (it == null) return;
it.Title = clean;
if (!it.Started)
it.OutputBase = MakeUnique(Path.Combine(_config.OutputFolder, FileNameUtil.Sanitize(clean)));
}
}
private async Task DownloadHlsAsync(DownloadItemViewModel item, HttpClient http)
{
var cts = new CancellationTokenSource(); item.Cts = cts;
try
{
item.Status = "다운로드 중";
Directory.CreateDirectory(_config.OutputFolder);
var dl = new HlsDownloader(http, _config);
var progress = new Progress<DownloadProgress>(p =>
{
item.Segments = p.Detail ?? $"{p.Completed}/{p.Total}";
item.Status = p.Stage switch { "merging" => "병합 중", "remuxing" => "리먹스 중", _ => "다운로드 중" };
if (p.Stage == "downloading") item.Progress = (int)Math.Round(p.Percent);
});
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
}
catch (OperationCanceledException) { item.Status = "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
finally
{
// #6 실패/취소 시 parts 정리 (성공 시엔 엔진이 정리)
if (item.Status != "완료")
{
try { var pd = item.OutputBase + ".parts"; if (Directory.Exists(pd)) Directory.Delete(pd, recursive: true); }
catch { }
}
}
}
private async Task DownloadDirectAsync(DownloadItemViewModel item, HttpClient http)
{
var cts = new CancellationTokenSource(); item.Cts = cts;
try
{
item.Status = "다운로드 중";
Directory.CreateDirectory(_config.OutputFolder);
var dl = new DirectDownloader(http, _config);
var progress = new Progress<DownloadProgress>(p =>
{
item.Segments = p.Detail ?? "";
if (p.OverridePercent >= 0)
{
item.IsIndeterminate = false;
item.Progress = (int)Math.Round(p.OverridePercent);
}
else item.IsIndeterminate = true; // #9 길이 미상 → 부정확 모드
});
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
}
catch (OperationCanceledException) { item.Status = "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
finally
{
// #6/#12 임시 .part 정리
try { var part = item.OutputBase + ".part"; if (File.Exists(part)) File.Delete(part); }
catch { }
}
}
private async Task DownloadDashAsync(DownloadItemViewModel item, HttpClient http)
{
var cts = new CancellationTokenSource(); item.Cts = cts;
try
{
item.Status = "다운로드 중";
Directory.CreateDirectory(_config.OutputFolder);
var dl = new DashDownloader(http, _config);
var progress = new Progress<DownloadProgress>(p =>
{
item.Segments = p.Detail ?? $"{p.Completed}/{p.Total}";
item.Status = p.Stage switch { "merging" => "병합 중", "remuxing" => "리먹스 중", _ => "다운로드 중" };
if (p.Stage == "downloading") item.Progress = (int)Math.Round(p.Percent);
});
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, progress, cts.Token);
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
}
catch (OperationCanceledException) { item.Status = "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
finally
{
if (item.Status != "완료")
{
try { var pd = item.OutputBase + ".parts"; if (Directory.Exists(pd)) Directory.Delete(pd, recursive: true); }
catch { }
}
}
}
private async Task DownloadSocialAsync(DownloadItemViewModel item)
{
var cts = new CancellationTokenSource(); item.Cts = cts;
string? cookiesPath = null;
try
{
item.Status = "다운로드 중";
item.IsIndeterminate = true;
Directory.CreateDirectory(_config.OutputFolder);
cookiesPath = await WriteCookiesAsync(item.MediaUrl);
var fmt = item.SelectedFormat ?? SocialPresets.All[0];
var dl = new YtDlpDownloader(_config);
var progress = new Progress<DownloadProgress>(p =>
{
item.Status = p.Stage switch { "remuxing" => "리먹스 중", _ => "다운로드 중" };
if (p.OverridePercent >= 0)
{
item.IsIndeterminate = false;
item.Progress = (int)Math.Round(p.OverridePercent);
}
else item.IsIndeterminate = true; // 길이 미상/병환 단계 → 부정확 모드
if (!string.IsNullOrEmpty(p.Detail)) item.Segments = p.Detail;
});
var final = await dl.DownloadAsync(item.MediaUrl, item.OutputBase, fmt, cookiesPath, progress, cts.Token);
item.OutputPath = final; item.Progress = 100; item.Status = "완료"; _log($"완료: {final}");
}
catch (OperationCanceledException) { item.Status = "취소됨"; }
catch (Exception e) { item.Status = "오류"; item.Error = e.Message; _log($"오류({item.Title}): {e.Message}"); }
finally
{
// 잔류 임시/fragment 정리(성공 여부 무관). 무관 파일 미삭제를 위해 outputBase 접두로 한정.
try { CleanupYtDlpTemp(item.OutputBase); } catch { }
try { if (cookiesPath != null && File.Exists(cookiesPath)) File.Delete(cookiesPath); } catch { }
}
}
/// <summary>카드 "화질 불러오기" 버튼용: yt-dlp -J 로 실제 포맷을 조회해 드롭다운을 교체.</summary>
public async Task FetchFormatsAsync(DownloadItemViewModel item)
{
if (item.IsFetchingFormats) return;
item.IsFetchingFormats = true;
string? cookiesPath = null;
try
{
cookiesPath = await WriteCookiesAsync(item.MediaUrl);
var dl = new YtDlpDownloader(_config);
var fetched = await dl.FetchFormatsAsync(item.MediaUrl, cookiesPath, CancellationToken.None);
if (fetched.Count > 0)
{
var prev = item.SelectedFormat;
item.Formats.Clear();
foreach (var f in fetched) item.Formats.Add(f);
item.SelectedFormat = item.Formats.FirstOrDefault(f => prev != null && f.Selector == prev.Selector)
?? item.Formats[0];
_log($"화질 {fetched.Count}개 조회됨: {item.Title}");
}
else _log("사용 가능한 화질을 조회하지 못했습니다(프리셋 유지)");
}
catch (Exception e) { _log($"화질 조회 오류: {e.Message}"); }
finally
{
item.IsFetchingFormats = false;
try { if (cookiesPath != null && File.Exists(cookiesPath)) File.Delete(cookiesPath); } catch { }
}
}
/// <summary>WebView2 쿠키를 Netscape 파일로 내보냄. UseCookiesForSocial 이고 쿠키가 있을 때만.</summary>
private async Task<string?> WriteCookiesAsync(Uri url)
{
if (!_config.UseCookiesForSocial) return null;
try
{
var cookies = await _getCookies(url);
if (cookies == null || cookies.Count == 0) return null;
var epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var records = cookies.Select(c => new CookieRecord(
c.Domain ?? url.Host, c.Path ?? "/", c.Name, c.Value, c.IsSecure, c.IsHttpOnly,
c.Expires <= epoch ? 0 : new DateTimeOffset(c.Expires).ToUnixTimeSeconds())).ToList();
var path = Path.Combine(Path.GetTempPath(), $"vd_cookies_{Guid.NewGuid():N}.txt");
CookieJarWriter.Write(path, records);
return path;
}
catch { return null; }
}
private static void CleanupYtDlpTemp(string outputBase)
{
var dir = Path.GetDirectoryName(outputBase);
var name = Path.GetFileName(outputBase);
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) return;
foreach (var f in Directory.EnumerateFiles(dir, name + ".*"))
{
var fn = f;
if (fn.EndsWith(".part", StringComparison.OrdinalIgnoreCase) ||
fn.EndsWith(".ytdl", StringComparison.OrdinalIgnoreCase) ||
fn.Contains(".part-Frag", StringComparison.OrdinalIgnoreCase)) File.Delete(f);
}
}
private static string CleanSocialTitle(string t)
{
// 사이트 접미사 제거
foreach (var suffix in new[] { " - YouTube", " | Instagram", " | TikTok", " on Twitter", " / X", " - Facebook" })
if (t.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) { t = t[..^suffix.Length]; break; }
return t.Trim();
}
private async Task<HttpClient> BuildHttpClientAsync(Uri url, string? referer)
{
var http = new HttpClient(SharedHandler, disposeHandler: false) { Timeout = TimeSpan.FromMinutes(10) };
http.DefaultRequestHeaders.UserAgent.ParseAdd(_config.UserAgent);
if (!string.IsNullOrEmpty(referer))
{
try { http.DefaultRequestHeaders.Referrer = new Uri(referer); } catch { }
}
// #11 쿠키를 이 HttpClient(=이 다운로드) 에만 스코프 — 공유 컨테이너의 프로세스 수준 누적/타사이트 누출 방지.
// WebView2 가 해당 URL 에 대해 돌려준 쿠키만 Cookie 헤더로 구성(TryAddWithoutValidation: 값 검증 느슨하게).
try
{
var cookies = await _getCookies(url);
if (cookies is { Count: > 0 })
{
var header = string.Join("; ", cookies.Select(c => $"{c.Name}={c.Value}"));
if (!string.IsNullOrEmpty(header))
http.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", header);
}
}
catch { }
return http;
}
private static readonly string[] OutExts =
{ ".mp4", ".ts", ".webm", ".mkv", ".m4v", ".mov", ".avi", ".m4a", ".mp3", ".ogg", ".opus", ".wav", ".flac" };
private static string MakeUnique(string base_)
{
var dir = Path.GetDirectoryName(base_) ?? ".";
var file = Path.GetFileName(base_);
var p = Path.Combine(dir, file);
int i = 1;
while (OutExts.Any(ext => File.Exists(p + ext)) || Directory.Exists(p + ".parts"))
p = Path.Combine(dir, $"{file} ({i++})");
return p;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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