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