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() : ""; + // / 태그는 헤더를 못 실음 → 쿼리 토큰도 허용(LAN 한정) + if (provided.Length == 0 && ctx.Request.Query.TryGetValue("t", out var q)) provided = q.ToString(); + if (!FixedTimeEquals(provided, token)) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + } + await next(); + }); + + // ── 페어링 ── + app.MapPost("/api/pair", async (HttpContext ctx) => + { + var body = await ctx.Request.ReadFromJsonAsync(); + if (body == null || !FixedTimeEquals(body.Token ?? "", token)) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + await ctx.Response.WriteAsJsonAsync(new { ok = true, device = options.DeviceName }); + }); + + // ── 큐 ── + var queue = options.Queue; + app.MapGet("/api/queue", async (HttpContext ctx) => + await ctx.Response.WriteAsJsonAsync(await queue.GetItemsAsync())); + + app.MapPost("/api/queue", async (HttpContext ctx) => + { + var body = await ctx.Request.ReadFromJsonAsync(); + if (body == null || !Uri.TryCreate(body.Url, UriKind.Absolute, out _)) + { + ctx.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + var id = await queue.AddAsync(body.Url, body.Title); + await ctx.Response.WriteAsJsonAsync(new { id }); + }); + + app.MapPost("/api/queue/{id}/pause", async (string id) => { await queue.PauseAsync(id); Results.Ok(); }); + app.MapPost("/api/queue/{id}/resume", async (string id) => { await queue.ResumeAsync(id); Results.Ok(); }); + app.MapPost("/api/queue/{id}/cancel", async (string id) => { await queue.CancelAsync(id); Results.Ok(); }); + + // ── 라이브러리 / 스트리밍 ── + app.MapGet("/api/library", async (HttpContext ctx) => + await ctx.Response.WriteAsJsonAsync(MediaLibrary.List(options.LibraryFolder))); + + app.MapGet("/stream/{id}", (string id) => + { + var file = MediaLibrary.Find(options.LibraryFolder, id); + return file == null + ? Results.NotFound() + : Results.File(file.Value.FullPath, "video/mp4", enableRangeProcessing: true); // 206 Partial Content → 시크 지원 + }); + + app.MapGet("/file/{id}", (string id) => + { + var file = MediaLibrary.Find(options.LibraryFolder, id); + return file == null ? Results.NotFound() : Results.File(file.Value.FullPath, "application/octet-stream", file.Value.FileName); + }); + + // ── 웹 컴패니언 (제로 설치 클라이언트) ── + app.MapGet("/", () => Results.Text(WebCompanion.Html, "text/html; charset=utf-8")); + + await app.StartAsync(); + var actualPort = options.Port; + if (actualPort == 0) + { + var m = app.Urls.SelectMany(u => System.Text.RegularExpressions.Regex.Matches(u, @":(\d+)")) + .Select(m2 => int.Parse(m2.Groups[1].Value)).FirstOrDefault(); + actualPort = m; + } + return new ServerHost(app, actualPort, token, options); + } + + public async ValueTask DisposeAsync() + { + await _app.StopAsync(); + await _app.DisposeAsync(); + } + + private static int GetFreePort() + { + using var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + l.Start(); + var port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; + } + + private static bool FixedTimeEquals(string a, string b) + { + var ha = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(a)); + var hb = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(b)); + return CryptographicOperations.FixedTimeEquals(ha, hb); + } + + private sealed record PairRequest(string? Token, string? Device); + private sealed record QueueAddRequest(string Url, string? Title); +} diff --git a/VideoDownloader.Server/ServerModels.cs b/VideoDownloader.Server/ServerModels.cs new file mode 100644 index 0000000..38af121 --- /dev/null +++ b/VideoDownloader.Server/ServerModels.cs @@ -0,0 +1,45 @@ +namespace VideoDownloader.Server; + +/// 다운로드 큐 항목 스냅shot (모바일/웹 컴패니언용). +public sealed record QueueItemDto(string Id, string Title, string Url, string Status, double Percent); + +/// 라이브러리(저장 폴더) 미디어 항목. +public sealed record LibraryItemDto(string Id, string FileName, long SizeBytes, DateTime Modified); + +/// 서버 구성. +public sealed class ServerOptions +{ + /// 0 이면 사용 가능한 포트 자동 할당. + public int Port { get; init; } + /// 페어링 토큰(Bearer). 비면 자동 생성. + public string Token { get; init; } = ""; + /// 라이브러리로 노출할 폴더. + public string LibraryFolder { get; init; } = ""; + /// 다운로드 큐 어댑터(호스트 앱이 구현 주입). + public IQueueAdapter Queue { get; init; } = new EmptyQueueAdapter(); + /// 기기 표시명(웹 컴패니언 헤더용). + public string DeviceName { get; init; } = Environment.MachineName; +} + +/// 호스트 앱의 다운로드 큐와 서버를 잇는 어댑터(UI 스레드 마샬링은 구현측 담당). +public interface IQueueAdapter +{ + Task> GetItemsAsync(); + Task AddAsync(string url, string? title); + Task PauseAsync(string id); + Task ResumeAsync(string id); + Task CancelAsync(string id); + event Action? Changed; +} + +/// 큐 없이 서버만 띄울 때의 기본 구현. +public sealed class EmptyQueueAdapter : IQueueAdapter +{ + public Task> GetItemsAsync() => + Task.FromResult>(Array.Empty()); + public Task AddAsync(string url, string? title) => Task.FromResult(""); + public Task PauseAsync(string id) => Task.CompletedTask; + public Task ResumeAsync(string id) => Task.CompletedTask; + public Task CancelAsync(string id) => Task.CompletedTask; + public event Action? Changed { add { } remove { } } +} diff --git a/VideoDownloader.Server/VideoDownloader.Server.csproj b/VideoDownloader.Server/VideoDownloader.Server.csproj new file mode 100644 index 0000000..36d4ea7 --- /dev/null +++ b/VideoDownloader.Server/VideoDownloader.Server.csproj @@ -0,0 +1,11 @@ + + + net8.0 + enable + enable + VideoDownloader.Server + + + + + diff --git a/VideoDownloader.Server/WebCompanion.cs b/VideoDownloader.Server/WebCompanion.cs new file mode 100644 index 0000000..d004869 --- /dev/null +++ b/VideoDownloader.Server/WebCompanion.cs @@ -0,0 +1,149 @@ +namespace VideoDownloader.Server; + +/// +/// 제로 설치 웹 컴패니언(PairDrop/Jellyfin 패턴): 폰 카메라로 QR 스캔 → +/// 브라우저에서 바로 큐 조회/URL 추가. 토큰은 URL 프래그먼트(#t=)로 전달되어 +/// 서버 로그/리퍼러에 남지 않는다. +/// +public static class WebCompanion +{ + public const string Html = """ + + + + + +VideoDownloader 원격 + + + +📺 VideoDownloader 연결중 + + + 링크 보내 PC에서 받기 + YouTube·웹페이지 URL 을 붙여넣으세요 + + 받기 요청 + + + + + 다운로드 큐 + 불러오는 중… + + + + PC 라이브러리 탭하여 스트리밍 재생 + 불러오는 중… + + +같은 Wi-Fi 에서만 접속됩니다 · LAN 직결(계정 없음) + + + + +"""; +} diff --git a/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs b/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs new file mode 100644 index 0000000..eb3de9f --- /dev/null +++ b/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs @@ -0,0 +1,111 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using VideoDownloader.Server; + +namespace VideoDownloader.Tests.Server; + +/// +/// E2E (M0): 실제 Kestrel 서버 기동 → 인증/페어링/큐 제어/라이브러리/스트리밍/웹 컴패니언 검증. +/// +public class RemoteServerE2ETests : IDisposable +{ + private readonly string _libDir = Path.Combine(Path.GetTempPath(), $"vd_srv_{Guid.NewGuid():N}"); + + private sealed class FakeQueue : IQueueAdapter + { + public List Items { get; } = new(); + public List Actions { get; } = new(); + + public Task> GetItemsAsync() => Task.FromResult>(Items); + public Task AddAsync(string url, string? title) + { + var item = new QueueItemDto(Guid.NewGuid().ToString("N"), title ?? url, url, "대기", 0); + Items.Add(item); + Changed?.Invoke(); + return Task.FromResult(item.Id); + } + public Task PauseAsync(string id) { Actions.Add($"pause:{id}"); return Task.CompletedTask; } + public Task ResumeAsync(string id) { Actions.Add($"resume:{id}"); return Task.CompletedTask; } + public Task CancelAsync(string id) { Actions.Add($"cancel:{id}"); return Task.CompletedTask; } + + public event Action? Changed; + } + + [Fact] + public async Task 서버_전_API_E2E_인증_큐_라이브러리_스트리밍_웹컴패니언() + { + Directory.CreateDirectory(_libDir); + var mediaPath = Path.Combine(_libDir, "sample.mp4"); + File.WriteAllBytes(mediaPath, new byte[256 * 1024]); // 256KB 가짜 미디어 + + var queue = new FakeQueue(); + await using var server = await ServerHost.StartAsync(new ServerOptions + { + Port = 0, // 사용 가능한 포트 자동 할당 + Token = "test-token-123", + LibraryFolder = _libDir, + Queue = queue, + }); + + using var http = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") }; + + // 1) 인증 없으면 401 + var anon = await http.GetAsync("/api/queue"); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, anon.StatusCode); + + // 2) 틀린 토큰도 401 + using var bad = new HttpRequestMessage(HttpMethod.Get, "/api/queue"); + bad.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "wrong"); + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await http.SendAsync(bad)).StatusCode); + + // 3) 페어링(올바른 토큰) + var pair = await http.PostAsJsonAsync("/api/pair", new { token = "test-token-123", device = "E2E-Phone" }); + Assert.True(pair.IsSuccessStatusCode); + + // 4) 큐: URL 추가 → 목록 조회 + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "test-token-123"); + var add = await http.PostAsJsonAsync("/api/queue", new { url = "https://x.test/v.mp4", title = "E2E 영상" }); + Assert.True(add.IsSuccessStatusCode); + var items = await http.GetFromJsonAsync>("/api/queue"); + Assert.NotNull(items); + Assert.Single(items!); + Assert.Equal("E2E 영상", items![0].Title); + var id = items[0].Id; + + // 5) 큐 제어 위임 + Assert.True((await http.PostAsync($"/api/queue/{id}/pause", null)).IsSuccessStatusCode); + Assert.True((await http.PostAsync($"/api/queue/{id}/resume", null)).IsSuccessStatusCode); + Assert.True((await http.PostAsync($"/api/queue/{id}/cancel", null)).IsSuccessStatusCode); + Assert.Equal(new[] { $"pause:{id}", $"resume:{id}", $"cancel:{id}" }, queue.Actions); + + // 6) 라이브러리 + var lib = await http.GetFromJsonAsync>("/api/library"); + Assert.NotNull(lib); + Assert.Single(lib!); + Assert.Equal("sample.mp4", lib![0].FileName); + + // 7) 스트리밍: Range 요청 → 206 부분 응답 + using var range = new HttpRequestMessage(HttpMethod.Get, $"/stream/{lib[0].Id}"); + range.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(0, 1023); + range.Headers.Authorization = new AuthenticationHeaderValue("Bearer", "test-token-123"); + var partial = await http.SendAsync(range); + Assert.Equal(System.Net.HttpStatusCode.PartialContent, partial.StatusCode); + var bytes = await partial.Content.ReadAsByteArrayAsync(); + Assert.Equal(1024, bytes.Length); + + // 8) 파일 다운로드: 전체 길이 + var file = await http.GetByteArrayAsync($"/file/{lib[0].Id}"); + Assert.Equal(256 * 1024, file.Length); + + // 9) 웹 컴패니언 페이지 제공 + var page = await http.GetStringAsync("/"); + Assert.Contains("VideoDownloader", page); + Assert.Contains("api/queue", page); // 페이지가 API 를 호출하는 스크립트 포함 + } + + public void Dispose() + { + try { Directory.Delete(_libDir, true); } catch { } + } +} diff --git a/VideoDownloader.Tests/VideoDownloader.Tests.csproj b/VideoDownloader.Tests/VideoDownloader.Tests.csproj index edc0567..be121f8 100644 --- a/VideoDownloader.Tests/VideoDownloader.Tests.csproj +++ b/VideoDownloader.Tests/VideoDownloader.Tests.csproj @@ -15,5 +15,6 @@ + diff --git a/VideoDownloader.slnx b/VideoDownloader.slnx index 6268e20..3a94118 100644 --- a/VideoDownloader.slnx +++ b/VideoDownloader.slnx @@ -4,5 +4,6 @@ +