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,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>
""";
}