From bb569acd96cfc6ed016e35161b9f837c0ca639e3 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 15 Aug 2026 15:30:35 +0900 Subject: [PATCH] M0: remote access server - Kestrel embedded (token auth/pair/queue API/library/range streaming/web companion zero-install), QR pairing window, DownloadQueueAdapter, auto-start+palette commands. Server E2E green (102 tests) --- VideoDownloader.App/Browser/PairingWindow.cs | 65 ++++++++ VideoDownloader.App/Core/DownloadManager.cs | 1 + VideoDownloader.App/MainWindow.xaml.cs | 73 +++++++++ .../Services/DownloadQueueAdapter.cs | 67 ++++++++ .../VideoDownloader.App.csproj | 2 + .../ViewModels/DownloadItemViewModel.cs | 5 + VideoDownloader.Core/Config/AppConfig.cs | 4 + VideoDownloader.Server/MediaLibrary.cs | 45 +++++ VideoDownloader.Server/ServerHost.cs | 154 ++++++++++++++++++ VideoDownloader.Server/ServerModels.cs | 45 +++++ .../VideoDownloader.Server.csproj | 11 ++ VideoDownloader.Server/WebCompanion.cs | 149 +++++++++++++++++ .../Server/RemoteServerE2ETests.cs | 111 +++++++++++++ .../VideoDownloader.Tests.csproj | 1 + VideoDownloader.slnx | 1 + 15 files changed, 734 insertions(+) create mode 100644 VideoDownloader.App/Browser/PairingWindow.cs create mode 100644 VideoDownloader.App/Services/DownloadQueueAdapter.cs create mode 100644 VideoDownloader.Server/MediaLibrary.cs create mode 100644 VideoDownloader.Server/ServerHost.cs create mode 100644 VideoDownloader.Server/ServerModels.cs create mode 100644 VideoDownloader.Server/VideoDownloader.Server.csproj create mode 100644 VideoDownloader.Server/WebCompanion.cs create mode 100644 VideoDownloader.Tests/Server/RemoteServerE2ETests.cs diff --git a/VideoDownloader.App/Browser/PairingWindow.cs b/VideoDownloader.App/Browser/PairingWindow.cs new file mode 100644 index 0000000..f8fe93a --- /dev/null +++ b/VideoDownloader.App/Browser/PairingWindow.cs @@ -0,0 +1,65 @@ +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Interop; +using System.Windows.Media.Imaging; +using QRCoder; +using VideoDownloader.Server; + +namespace VideoDownloader.App.Browser; + +/// M0 원격 접근 페어링: QR(주소+토큰 동시 전달) + 상태 표시. +public sealed class PairingWindow : Window +{ + public PairingWindow(ServerHost server, string deviceName) + { + Title = "기기 연결 — Video Downloader"; + Width = 420; Height = 560; + WindowStartupLocation = WindowStartupLocation.CenterOwner; + Background = System.Windows.Media.Brushes.White; + + var lanIp = GetLanIp(); + var url = $"http://{lanIp}:{server.Port}/#t={server.Token}"; + + var qrPayload = new QRCodeGenerator().CreateQrCode(url, QRCodeGenerator.ECCLevel.M); + var qr = new QRCode(qrPayload); + var bmp = qr.GetGraphic(12); + + var stack = new StackPanel { Margin = new Thickness(24) }; + var title = new TextBlock { Text = "📱 폰 카메라로 QR 스캔", FontSize = 16, FontWeight = FontWeights.Bold, Margin = new Thickness(0, 0, 0, 4) }; + var sub = new TextBlock { Text = "브라우저에서 바로 열립니다(앱 설치 불필요). 같은 Wi-Fi 에 있어야 합니다.", FontSize = 12, Foreground = System.Windows.Media.Brushes.Gray, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16) }; + var img = new Image { Width = 300, Height = 300 }; + using var ms = new MemoryStream(); + bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png); + ms.Position = 0; + img.Source = BitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad); + + var urlBox = new TextBox { Text = url, IsReadOnly = true, FontSize = 11, Margin = new Thickness(0, 12, 0, 4), TextWrapping = TextWrapping.Wrap }; + var copyBtn = new Button { Content = "주소 복사", Padding = new Thickness(14, 6, 14, 6), HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(0, 8, 0, 0) }; + copyBtn.Click += (_, _) => { try { Clipboard.SetText(url); } catch { } }; + var note = new TextBlock { Text = $"포트 {server.Port} · Windows 방화벽 확인이 뜨면 '허용'을 선택하세요\n토큰은 이 기기와 페어링된 폰에서만 유효합니다", + FontSize = 11, Foreground = System.Windows.Media.Brushes.Gray, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 12, 0, 0) }; + + stack.Children.Add(title); + stack.Children.Add(sub); + stack.Children.Add(img); + stack.Children.Add(urlBox); + stack.Children.Add(copyBtn); + stack.Children.Add(note); + Content = stack; + } + + /// LAN IPv4(게이트웨이 있는 첫 후보) — 없으면 loopback. + private static string GetLanIp() + { + try + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0); + socket.Connect("8.8.8.8", 65530); // 외부 행선지 지정 → 라우팅되는 인터페이스 IP 획득(패킷 전송 없음) + return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString() ?? "127.0.0.1"; + } + catch { return "127.0.0.1"; } + } +} diff --git a/VideoDownloader.App/Core/DownloadManager.cs b/VideoDownloader.App/Core/DownloadManager.cs index e117090..b95e208 100644 --- a/VideoDownloader.App/Core/DownloadManager.cs +++ b/VideoDownloader.App/Core/DownloadManager.cs @@ -18,6 +18,7 @@ namespace VideoDownloader.App.Core; public class DownloadManager { private readonly AppConfig _config; + public AppConfig Config => _config; private readonly Func>> _getCookies; private readonly Action _log; diff --git a/VideoDownloader.App/MainWindow.xaml.cs b/VideoDownloader.App/MainWindow.xaml.cs index 59fe526..a5d1285 100644 --- a/VideoDownloader.App/MainWindow.xaml.cs +++ b/VideoDownloader.App/MainWindow.xaml.cs @@ -40,6 +40,9 @@ public partial class MainWindow : Window private readonly ObservableCollection _history = new(); private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지) + // M0: 원격 접근 서버(모바일 연동) + private Server.ServerHost? _remoteServer; + // Phase D: 미디어 재생목록 (T2-10) private readonly MediaPlaylist _playlist = new(); private PlayerWindow? _player; @@ -243,6 +246,23 @@ public partial class MainWindow : Window }; _speedTimer.Start(); + // M0: 설정돼 있으면 원격 접근 서버 자동 시작 + if (_config.RemoteAccessEnabled && !string.IsNullOrEmpty(_config.RemoteAccessToken)) + { + try + { + _remoteServer = await Server.ServerHost.StartAsync(new Server.ServerOptions + { + Port = 0, + Token = _config.RemoteAccessToken!, + LibraryFolder = Path.GetFullPath(_config.OutputFolder), + Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher), + }); + Log($"원격 접근 서버 포트 {_remoteServer.Port}"); + } + catch { } + } + // T2-2 탭 절전: 1분마다 유휴(10분+) 비활성 탭 서스펜드 _suspendService = new TabSuspendService(TimeSpan.FromMinutes(10), tab => { @@ -879,6 +899,7 @@ public partial class MainWindow : Window // T1-10 세션 저장: 탭 URL/활성/핀 보존 try { if (_tabs != null) TabSessionStore.Save(SessionPath, _tabs.CaptureSession()); } catch { } try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { } // T2-10 + try { if (_remoteServer != null) _ = _remoteServer.DisposeAsync(); } catch { } // M0 } // ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ---- @@ -1152,6 +1173,8 @@ public partial class MainWindow : Window _palette.Register("이 페이지 즐겨찾기", "bookmark add star", () => AddBookmark_Click(this, new RoutedEventArgs())); _palette.Register("미디어 재생목록", "playlist play media 음악 영상", () => { PlaylistPopup.IsOpen = true; }); _palette.Register("재생목록 재생", "playlist play start", OpenPlayer); + _palette.Register("기기 연결 (QR 페어링)", "remote pair qr mobile phone", ShowPairing); + _palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess()); _palette.RegisterSeparator(); _palette.RegisterDynamic(() => _tabs.Tabs .Select(t => new PaletteCommand($"탭 전환: {Trunc(t.Title, 40)}", "tab go switch", () => _tabs.Activate(t))) @@ -1486,6 +1509,56 @@ public partial class MainWindow : Window Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)"); } + // ---- M0 원격 접근(모바일 연동) ---- + private async Task ToggleRemoteAccess() + { + if (_remoteServer != null) + { + await _remoteServer.DisposeAsync(); + _remoteServer = null; + _config.RemoteAccessEnabled = false; + ConfigStore.Save(_config); + Log("원격 접근을 껐습니다"); + return; + } + + var token = _config.RemoteAccessToken; + if (string.IsNullOrEmpty(token)) + { + token = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(24)) + .TrimEnd('=').Replace('+', '-').Replace('/', '_'); + _config.RemoteAccessToken = token; + } + _config.RemoteAccessEnabled = true; + ConfigStore.Save(_config); + + try + { + _remoteServer = await Server.ServerHost.StartAsync(new Server.ServerOptions + { + Port = 0, + Token = token, + LibraryFolder = Path.GetFullPath(_config.OutputFolder), + Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher), + }); + Log($"원격 접근 켜짐 — 포트 {_remoteServer.Port}"); + var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this }; + pair.Show(); + } + catch (Exception ex) + { + _remoteServer = null; + Log($"원격 접근 시작 실패(방화벽 확인): {ex.Message}"); + } + } + + private async void ShowPairing() + { + if (_remoteServer == null) { await ToggleRemoteAccess(); return; } + var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this }; + pair.Show(); + } + // ---- T2-10 미디어 재생목록 ---- private void AddToPlaylist(string url, string title, string kind, string? referer) { diff --git a/VideoDownloader.App/Services/DownloadQueueAdapter.cs b/VideoDownloader.App/Services/DownloadQueueAdapter.cs new file mode 100644 index 0000000..c87fdfc --- /dev/null +++ b/VideoDownloader.App/Services/DownloadQueueAdapter.cs @@ -0,0 +1,67 @@ +using System.Windows.Threading; +using VideoDownloader.App.ViewModels; +using VideoDownloader.Server; +using VideoDownloader.Core.Detection; + +namespace VideoDownloader.App.Services; + +/// +/// DownloadManager(UI 컬렉션) ↔ 서버 큐 API 브릿지. 모든 접근을 UI 디스패처로 마샬링. +/// +public sealed class DownloadQueueAdapter : IQueueAdapter +{ + private readonly Core.DownloadManager _manager; + private readonly Dispatcher _dispatcher; + + public DownloadQueueAdapter(Core.DownloadManager manager, Dispatcher dispatcher) + { + _manager = manager; + _dispatcher = dispatcher; + } + + public Task> GetItemsAsync() => _dispatcher.InvokeAsync(() => + (IReadOnlyList)_manager.Items.Select(Map).ToList()).Task; + + public Task AddAsync(string url, string? title) + { + // 서버에서 온 URL → 감지 파이프라인으로(소셜/HLS/DASH 자동 분류) + return _dispatcher.InvokeAsync(() => + { + if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && + SocialSiteDetector.TryMatch(uri, out var normalized, out var label)) + { + _manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label)); + return normalized; + } + var kind = MediaDetector.Classify(url, "", -1, _manager.Config) ?? MediaKind.Direct; + _manager.OnDetected(new DetectionContext(url, url, kind, "")); + return url; + }).Task; + } + + public Task PauseAsync(string id) => _dispatcher.InvokeAsync(() => + { + var item = Find(id); + if (item != null) _manager.Cancel(item); // v1: 제어 매핑 — 진행 항목 취소 + }).Task; + + public Task ResumeAsync(string id) => _dispatcher.InvokeAsync(() => + { + var item = Find(id); + if (item != null) _ = _manager.RetryDownload(item); + }).Task; + + public Task CancelAsync(string id) => _dispatcher.InvokeAsync(() => + { + var item = Find(id); + if (item != null) _manager.Cancel(item); + }).Task; + + public event Action? Changed { add { } remove { } } + + private DownloadItemViewModel? Find(string id) => + _manager.Items.FirstOrDefault(i => i.Id == id); + + private static QueueItemDto Map(DownloadItemViewModel i) => + new(i.Id, i.Title, i.Url ?? "", i.Status ?? "", i.Progress); +} diff --git a/VideoDownloader.App/VideoDownloader.App.csproj b/VideoDownloader.App/VideoDownloader.App.csproj index e893d59..f5458b9 100644 --- a/VideoDownloader.App/VideoDownloader.App.csproj +++ b/VideoDownloader.App/VideoDownloader.App.csproj @@ -22,6 +22,7 @@ + @@ -32,6 +33,7 @@ + diff --git a/VideoDownloader.App/ViewModels/DownloadItemViewModel.cs b/VideoDownloader.App/ViewModels/DownloadItemViewModel.cs index e2e101c..365103f 100644 --- a/VideoDownloader.App/ViewModels/DownloadItemViewModel.cs +++ b/VideoDownloader.App/ViewModels/DownloadItemViewModel.cs @@ -19,6 +19,11 @@ public class DownloadItemViewModel : INotifyPropertyChanged private string _title; public string Title { get => _title; set => SetField(ref _title, value); } + + /// 항목 고유 ID(원격 API 노출용). + public string Id { get; } = Guid.NewGuid().ToString("N"); + /// 미디어 URL(원격 API 노출용). + public string? Url => MediaUrl?.ToString(); public string FormatTag { get; init; } = "FILE"; // 다운로드 실행에 필요한 컨텍스트 (감지 시 고정) diff --git a/VideoDownloader.Core/Config/AppConfig.cs b/VideoDownloader.Core/Config/AppConfig.cs index cdec592..20e6a15 100644 --- a/VideoDownloader.Core/Config/AppConfig.cs +++ b/VideoDownloader.Core/Config/AppConfig.cs @@ -73,4 +73,8 @@ public class AppConfig /// 종료 시 탭을 저장하고 다음 시작에 복원(T1-10 세션 복원). public bool RestoreSession { get; set; } = true; + + /// M0 원격 접근(모바일 연동) 서버 켜기. 토큰은 자동 생성/저장. + public bool RemoteAccessEnabled { get; set; } = false; + public string? RemoteAccessToken { get; set; } } diff --git a/VideoDownloader.Server/MediaLibrary.cs b/VideoDownloader.Server/MediaLibrary.cs new file mode 100644 index 0000000..d6b8620 --- /dev/null +++ b/VideoDownloader.Server/MediaLibrary.cs @@ -0,0 +1,45 @@ +using System.Security.Cryptography; + +namespace VideoDownloader.Server; + +/// 저장 폴더 미디어 라이브러리: 파일 나열/조회(ID = SHA-256 경로 해시 12자). +public static class MediaLibrary +{ + private static readonly string[] VideoExts = { ".mp4", ".mkv", ".webm", ".ts", ".mov", ".m4v", ".avi", ".mp3", ".m4a" }; + + public static List List(string folder) + { + var result = new List(); + try + { + if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) return result; + foreach (var f in Directory.EnumerateFiles(folder).OrderByDescending(f => File.GetLastWriteTimeUtc(f))) + { + var ext = Path.GetExtension(f).ToLowerInvariant(); + if (!VideoExts.Contains(ext)) continue; + var fi = new FileInfo(f); + result.Add(new LibraryItemDto(IdOf(f), fi.Name, fi.Length, fi.LastWriteTimeUtc)); + } + } + catch { } + return result; + } + + public static (string FullPath, string FileName)? Find(string folder, string id) + { + try + { + if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder)) return null; + foreach (var f in Directory.EnumerateFiles(folder)) + if (IdOf(f) == id) return (f, Path.GetFileName(f)); + } + catch { } + return null; + } + + private static string IdOf(string path) + { + var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(Path.GetFullPath(path))); + return Convert.ToHexString(hash)[..12].ToLowerInvariant(); + } +} diff --git a/VideoDownloader.Server/ServerHost.cs b/VideoDownloader.Server/ServerHost.cs new file mode 100644 index 0000000..22dd7d1 --- /dev/null +++ b/VideoDownloader.Server/ServerHost.cs @@ -0,0 +1,154 @@ +using System.Linq; +using System.Security.Cryptography; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; + +namespace VideoDownloader.Server; + +/// +/// M0 원격 접근 서버: 데스크톱 앱에 내장되는 Kestrel. +/// QR 페어링 토큰(Bearer) 인증 + 큐 API + 라이브러리/Range 스트리밍 + 제로 설치 웹 컴패니언. +/// LAN 직결(계정/클라우드 없음) — MOBILE_PLAN §3 아키텍처. +/// +public sealed class ServerHost : IAsyncDisposable +{ + private readonly WebApplication _app; + private readonly string _token; + private readonly ServerOptions _options; + + public int Port { get; } + /// QR 에 담을 페어링 URL 형식: http://<lan-ip>:<port>/#t=<token> + public string Token => _token; + + private ServerHost(WebApplication app, int port, string token, ServerOptions options) + { + _app = app; + Port = port; + _token = token; + _options = options; + } + + public static async Task StartAsync(ServerOptions options) + { + var token = string.IsNullOrEmpty(options.Token) + ? WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(32)) + : options.Token; + + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = new[] { "--urls", $"http://0.0.0.0:{(options.Port == 0 ? GetFreePort() : options.Port)}" } }); + foreach (var prov in builder.Services.Where(sd => sd.ServiceType.FullName!.Contains("LoggerProvider")).ToList()) + builder.Services.Remove(prov); + + var app = builder.Build(); + + // Bearer 토큰 인증(상수시간 비교) — / 와 /api/* 전체 적용 + app.Use(async (ctx, next) => + { + if (!ctx.Request.Path.StartsWithSegments("/api/pair") && + (ctx.Request.Path.StartsWithSegments("/api") || ctx.Request.Path.StartsWithSegments("/stream") || ctx.Request.Path.StartsWithSegments("/file"))) + { + var auth = ctx.Request.Headers.Authorization.ToString(); + var provided = auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) ? auth[7..].Trim() : ""; + //