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:
parent
40c1b39b70
commit
bb569acd96
15 changed files with 734 additions and 0 deletions
45
VideoDownloader.Server/MediaLibrary.cs
Normal file
45
VideoDownloader.Server/MediaLibrary.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
154
VideoDownloader.Server/ServerHost.cs
Normal file
154
VideoDownloader.Server/ServerHost.cs
Normal 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://<lan-ip>:<port>/#t=<token></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);
|
||||
}
|
||||
45
VideoDownloader.Server/ServerModels.cs
Normal file
45
VideoDownloader.Server/ServerModels.cs
Normal 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 { } }
|
||||
}
|
||||
11
VideoDownloader.Server/VideoDownloader.Server.csproj
Normal file
11
VideoDownloader.Server/VideoDownloader.Server.csproj
Normal 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>
|
||||
149
VideoDownloader.Server/WebCompanion.cs
Normal file
149
VideoDownloader.Server/WebCompanion.cs
Normal 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 => ({'&':'&','<':'<','>':'>','"':'"'}[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>
|
||||
""";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue