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)

This commit is contained in:
Yun Chan 2026-08-15 15:30:35 +09:00
parent 40c1b39b70
commit bb569acd96
15 changed files with 734 additions and 0 deletions

View file

@ -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;
/// <summary>M0 원격 접근 페어링: QR(주소+토큰 동시 전달) + 상태 표시.</summary>
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;
}
/// <summary>LAN IPv4(게이트웨이 있는 첫 후보) — 없으면 loopback.</summary>
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"; }
}
}

View file

@ -18,6 +18,7 @@ namespace VideoDownloader.App.Core;
public class DownloadManager
{
private readonly AppConfig _config;
public AppConfig Config => _config;
private readonly Func<Uri, Task<List<CoreWebView2Cookie>>> _getCookies;
private readonly Action<string> _log;

View file

@ -40,6 +40,9 @@ public partial class MainWindow : Window
private readonly ObservableCollection<HistoryEntry> _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)
{

View file

@ -0,0 +1,67 @@
using System.Windows.Threading;
using VideoDownloader.App.ViewModels;
using VideoDownloader.Server;
using VideoDownloader.Core.Detection;
namespace VideoDownloader.App.Services;
/// <summary>
/// DownloadManager(UI 컬렉션) ↔ 서버 큐 API 브릿지. 모든 접근을 UI 디스패처로 마샬링.
/// </summary>
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<IReadOnlyList<QueueItemDto>> GetItemsAsync() => _dispatcher.InvokeAsync(() =>
(IReadOnlyList<QueueItemDto>)_manager.Items.Select(Map).ToList()).Task;
public Task<string> 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);
}

View file

@ -22,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
<ProjectReference Include="..\VideoDownloader.Browser\VideoDownloader.Browser.csproj" />
<ProjectReference Include="..\VideoDownloader.Server\VideoDownloader.Server.csproj" />
</ItemGroup>
<!-- .NET 10 SDK 의 net8.0-windows WPF implicit usings 보강 -->
@ -32,6 +33,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4078.44" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="System.Speech" Version="8.0.0" />
</ItemGroup>

View file

@ -19,6 +19,11 @@ public class DownloadItemViewModel : INotifyPropertyChanged
private string _title;
public string Title { get => _title; set => SetField(ref _title, value); }
/// <summary>항목 고유 ID(원격 API 노출용).</summary>
public string Id { get; } = Guid.NewGuid().ToString("N");
/// <summary>미디어 URL(원격 API 노출용).</summary>
public string? Url => MediaUrl?.ToString();
public string FormatTag { get; init; } = "FILE";
// 다운로드 실행에 필요한 컨텍스트 (감지 시 고정)

View file

@ -73,4 +73,8 @@ public class AppConfig
/// <summary>종료 시 탭을 저장하고 다음 시작에 복원(T1-10 세션 복원).</summary>
public bool RestoreSession { get; set; } = true;
/// <summary>M0 원격 접근(모바일 연동) 서버 켜기. 토큰은 자동 생성/저장.</summary>
public bool RemoteAccessEnabled { get; set; } = false;
public string? RemoteAccessToken { get; set; }
}

View file

@ -0,0 +1,45 @@
using System.Security.Cryptography;
namespace VideoDownloader.Server;
/// <summary>저장 폴더 미디어 라이브러리: 파일 나열/조회(ID = SHA-256 경로 해시 12자).</summary>
public static class MediaLibrary
{
private static readonly string[] VideoExts = { ".mp4", ".mkv", ".webm", ".ts", ".mov", ".m4v", ".avi", ".mp3", ".m4a" };
public static List<LibraryItemDto> List(string folder)
{
var result = new List<LibraryItemDto>();
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();
}
}

View file

@ -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;
/// <summary>
/// M0 원격 접근 서버: 데스크톱 앱에 내장되는 Kestrel.
/// QR 페어링 토큰(Bearer) 인증 + 큐 API + 라이브러리/Range 스트리밍 + 제로 설치 웹 컴패니언.
/// LAN 직결(계정/클라우드 없음) — MOBILE_PLAN §3 아키텍처.
/// </summary>
public sealed class ServerHost : IAsyncDisposable
{
private readonly WebApplication _app;
private readonly string _token;
private readonly ServerOptions _options;
public int Port { get; }
/// <summary>QR 에 담을 페어링 URL 형식: http://&lt;lan-ip&gt;:&lt;port&gt;/#t=&lt;token&gt;</summary>
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<ServerHost> 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() : "";
// <video>/<a> 태그는 헤더를 못 실음 → 쿼리 토큰도 허용(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<PairRequest>();
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<QueueAddRequest>();
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);
}

View file

@ -0,0 +1,45 @@
namespace VideoDownloader.Server;
/// <summary>다운로드 큐 항목 스냅shot (모바일/웹 컴패니언용).</summary>
public sealed record QueueItemDto(string Id, string Title, string Url, string Status, double Percent);
/// <summary>라이브러리(저장 폴더) 미디어 항목.</summary>
public sealed record LibraryItemDto(string Id, string FileName, long SizeBytes, DateTime Modified);
/// <summary>서버 구성.</summary>
public sealed class ServerOptions
{
/// <summary>0 이면 사용 가능한 포트 자동 할당.</summary>
public int Port { get; init; }
/// <summary>페어링 토큰(Bearer). 비면 자동 생성.</summary>
public string Token { get; init; } = "";
/// <summary>라이브러리로 노출할 폴더.</summary>
public string LibraryFolder { get; init; } = "";
/// <summary>다운로드 큐 어댑터(호스트 앱이 구현 주입).</summary>
public IQueueAdapter Queue { get; init; } = new EmptyQueueAdapter();
/// <summary>기기 표시명(웹 컴패니언 헤더용).</summary>
public string DeviceName { get; init; } = Environment.MachineName;
}
/// <summary>호스트 앱의 다운로드 큐와 서버를 잇는 어댑터(UI 스레드 마샬링은 구현측 담당).</summary>
public interface IQueueAdapter
{
Task<IReadOnlyList<QueueItemDto>> GetItemsAsync();
Task<string> AddAsync(string url, string? title);
Task PauseAsync(string id);
Task ResumeAsync(string id);
Task CancelAsync(string id);
event Action? Changed;
}
/// <summary>큐 없이 서버만 띄울 때의 기본 구현.</summary>
public sealed class EmptyQueueAdapter : IQueueAdapter
{
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() =>
Task.FromResult<IReadOnlyList<QueueItemDto>>(Array.Empty<QueueItemDto>());
public Task<string> 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 { } }
}

View file

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>VideoDownloader.Server</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,149 @@
namespace VideoDownloader.Server;
/// <summary>
/// 제로 설치 웹 컴패니언(PairDrop/Jellyfin 패턴): 폰 카메라로 QR 스캔 →
/// 브라우저에서 바로 큐 조회/URL 추가. 토큰은 URL 프래그먼트(#t=)로 전달되어
/// 서버 로그/리퍼러에 남지 않는다.
/// </summary>
public static class WebCompanion
{
public const string Html = """
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>VideoDownloader 원격</title>
<style>
:root { color-scheme: dark; }
body { background:#14151d; color:#e6e6ec; font-family:'Segoe UI',system-ui,sans-serif; margin:0; padding:20px 16px 40px; max-width:680px; margin-inline:auto; }
h1 { font-size:1.25rem; display:flex; align-items:center; gap:10px; }
.badge { background:#5b8def; color:#fff; border-radius:8px; padding:2px 10px; font-size:.75rem; }
.card { background:#1e2030; border:1px solid #2a2c42; border-radius:14px; padding:14px; margin:14px 0; }
input[type=text] { width:100%; box-sizing:border-box; padding:12px 14px; border-radius:10px; border:1px solid #3a3c55; background:#14151d; color:#e6e6ec; font-size:15px; }
button { background:#5b8def; color:#fff; border:none; border-radius:10px; padding:11px 18px; font-size:14px; font-weight:600; margin-top:10px; cursor:pointer; }
button.sec { background:#2a2c42; }
.item { background:#1e2030; border:1px solid #2a2c42; border-radius:12px; padding:12px; margin:8px 0; display:flex; justify-content:space-between; align-items:center; gap:10px; }
.item .t { font-weight:600; font-size:.95rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.item .s { color:#9a9ab0; font-size:.8rem; }
.pbar { height:6px; background:#2a2c42; border-radius:4px; overflow:hidden; flex:1; }
.pbar>div { height:100%; background:#5b8def; }
.row { display:flex; gap:8px; align-items:center; }
.muted { color:#9a9ab0; font-size:.85rem; }
a { color:#5b8def; }
video { width:100%; border-radius:12px; background:#000; margin-top:8px; }
.err { color:#ff6b6b; }
</style>
</head>
<body>
<h1>📺 VideoDownloader <span class="badge" id="dev">연결중</span></h1>
<div class="card">
<b>링크 보내 PC에서 받기</b>
<div class="muted">YouTube·웹페이지 URL 을 붙여넣으세요</div>
<input type="text" id="url" placeholder="https://... " autocomplete="off">
<button onclick="addUrl()">받기 요청</button>
<span id="addMsg" class="muted"></span>
</div>
<div class="card">
<b>다운로드 큐</b> <span class="muted" id="qcount"></span>
<div id="queue"><div class="muted">불러오는 중…</div></div>
</div>
<div class="card">
<b>PC 라이브러리</b> <span class="muted">탭하여 스트리밍 재생</span>
<div id="lib"><div class="muted">불러오는 중…</div></div>
</div>
<div class="muted" style="margin-top:24px">같은 Wi-Fi 에서만 접속됩니다 · LAN 직결(계정 없음)</div>
<script>
const token = (location.hash.match(/t=([^&]+)/) || [])[1] || '';
if (!token) document.body.innerHTML = '<div class="card err">토큰이 없습니다. PC 앱의 QR 코드로 다시 접속하세요.</div>';
const H = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
async function api(path, opts) {
const r = await fetch(path, Object.assign({ headers: H }, opts));
if (r.status === 401) { document.getElementById('dev').textContent = '인증 실패'; throw new Error('401'); }
return r;
}
async function addUrl() {
const url = document.getElementById('url').value.trim();
if (!url) return;
document.getElementById('addMsg').textContent = '전송 중…';
try {
const r = await api('/api/queue', { method: 'POST', body: JSON.stringify({ url, title: url }) });
document.getElementById('addMsg').textContent = r.ok ? '✅ PC 대기열에 추가됨' : '❌ 실패';
document.getElementById('url').value = '';
refreshQueue();
} catch (e) {}
}
async function refreshQueue() {
try {
const items = await (await api('/api/queue')).json();
const el = document.getElementById('queue');
document.getElementById('qcount').textContent = items.length + '건';
if (!items.length) { el.innerHTML = '<div class="muted">대기 중인 항목이 없습니다</div>'; return; }
el.innerHTML = items.map(i => `
<div class="item">
<div style="flex:1;min-width:0">
<div class="t">${esc(i.title)}</div>
<div class="row" style="margin-top:6px">
<div class="pbar"><div style="width:${Math.round(i.percent)}%"></div></div>
<span class="s">${esc(i.status)} ${Math.round(i.percent)}%</span>
</div>
</div>
<div class="row">
<button class="sec" onclick="ctl('${i.id}','pause')">⏸</button>
<button class="sec" onclick="ctl('${i.id}','resume')">▶</button>
<button class="sec" onclick="ctl('${i.id}','cancel')">✕</button>
</div>
</div>`).join('');
} catch (e) {}
}
async function ctl(id, action) { try { await api('/api/queue/' + id + '/' + action, { method: 'POST' }); refreshQueue(); } catch (e) {} }
async function refreshLib() {
try {
const items = await (await api('/api/library')).json();
const el = document.getElementById('lib');
if (!items.length) { el.innerHTML = '<div class="muted">저장 폴더가 비었습니다</div>'; return; }
el.innerHTML = items.map(i => `
<div class="item">
<div class="t" style="flex:1">${esc(i.fileName)}</div>
<span class="s">${fmtSize(i.sizeBytes)}</span>
<button class="sec" onclick="play('${i.id}')">▶ 재생</button>
</div>`).join('');
} catch (e) {}
}
function play(id) {
let v = document.getElementById('pv');
if (!v) {
v = document.createElement('video');
v.id = 'pv'; v.controls = true;
document.body.appendChild(v);
v.scrollIntoView({ behavior: 'smooth' });
}
v.src = '/stream/' + id + '?t=' + encodeURIComponent(token); // <video> 는 헤더 불가 → 쿼리 토큰
v.play().catch(()=>{});
}
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
function fmtSize(b) { return b > 1048576 ? (b/1048576).toFixed(1) + ' MB' : (b/1024).toFixed(0) + ' KB'; }
(async function init() {
const r = await fetch('/api/pair', { method: 'POST', headers: H, body: JSON.stringify({ token, device: navigator.userAgent.slice(0, 40) }) });
document.getElementById('dev').textContent = r.ok ? '연결됨' : '인증 실패';
refreshQueue(); refreshLib();
setInterval(refreshQueue, 2000); // v1 폴링 (SignalR 는 앱 확장 시)
})();
</script>
</body>
</html>
""";
}

View file

@ -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;
/// <summary>
/// E2E (M0): 실제 Kestrel 서버 기동 → 인증/페어링/큐 제어/라이브러리/스트리밍/웹 컴패니언 검증.
/// </summary>
public class RemoteServerE2ETests : IDisposable
{
private readonly string _libDir = Path.Combine(Path.GetTempPath(), $"vd_srv_{Guid.NewGuid():N}");
private sealed class FakeQueue : IQueueAdapter
{
public List<QueueItemDto> Items { get; } = new();
public List<string> Actions { get; } = new();
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => Task.FromResult<IReadOnlyList<QueueItemDto>>(Items);
public Task<string> 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<List<QueueItemDto>>("/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<List<LibraryItemDto>>("/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 { }
}
}

View file

@ -15,5 +15,6 @@
<ItemGroup>
<ProjectReference Include="..\VideoDownloader.App\VideoDownloader.App.csproj" />
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
<ProjectReference Include="..\VideoDownloader.Server\VideoDownloader.Server.csproj" />
</ItemGroup>
</Project>

View file

@ -4,5 +4,6 @@
<Project Path="VideoDownloader.Browser/VideoDownloader.Browser.csproj" />
<Project Path="VideoDownloader.Cli/VideoDownloader.Cli.csproj" />
<Project Path="VideoDownloader.Core/VideoDownloader.Core.csproj" />
<Project Path="VideoDownloader.Server/VideoDownloader.Server.csproj" />
<Project Path="VideoDownloader.Tests/VideoDownloader.Tests.csproj" />
</Solution>