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; /// Phase 2 Gate #0: 크로스플랫폼 셸의 최소 뷰모델. /// URL 을 붙여넣으면 유형(HLS/DASH/소셜/직링크) 을 판별해 Core 다운로더를 직접 호출해 진행률을 표시. /// 브라우저/감지/쿠키(Gate #1) 는 아직 없으므로 DownloadManager 가 아닌 Core 엔진을 직접 사용. 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 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(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 DownloadByKindAsync(Uri uri, Kind kind, string outputBase, IProgress 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)); }