releases/VideoDownloader.Server/ServerHost.cs

245 lines
11 KiB
C#

using System.Linq;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.SignalR;
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;
private readonly Action? _unsubscribeQueueChanged;
private readonly MdnsAdvertiser? _mdns;
public int Port { get; }
/// <summary>QR 에 담을 페어링 URL 형식: http://&lt;lan-ip&gt;:&lt;port&gt;/#t=&lt;token&gt;</summary>
public string Token => _token;
/// <summary>mDNS 광고(_videodl._tcp) 동작 여부. EnableMdns=true 인데 false 면 MdnsError 를 본다.</summary>
public bool MdnsActive => _mdns != null;
/// <summary>mDNS 시작 실패 원인(발견 보조 기능이라 서버 기동은 막지 않고 여기로 드러냄).</summary>
public Exception? MdnsError { get; }
private ServerHost(WebApplication app, int port, string token, ServerOptions options,
Action? unsubscribeQueueChanged, MdnsAdvertiser? mdns, Exception? mdnsError)
{
_app = app;
Port = port;
_token = token;
_options = options;
_unsubscribeQueueChanged = unsubscribeQueueChanged;
_mdns = mdns;
MdnsError = mdnsError;
}
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);
builder.Services.AddSingleton<IQueueAdapter>(options.Queue);
builder.Services.AddSignalR();
var app = builder.Build();
app.UseWebSockets(); // SignalR 웹소켓 전송용 (없으면 롱폴링만 됨)
// 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") || ctx.Request.Path.StartsWithSegments("/hubs")))
{
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();
// SignalR 클라이언트 관례(AccessTokenProvider)는 access_token 쿼리로 실어 보냄
if (provided.Length == 0 && ctx.Request.Query.TryGetValue("access_token", out var at)) provided = at.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(); });
// ── 라이브러리 / 스트리밍 ──
// ── 라이브러리 + 썸네일/해상도(M2) ──
var thumbnailer = options.Thumbnailer ?? ServerOptionsDefaults.DefaultThumbnailer();
var ffmpeg = options.FfmpegPath ?? VideoDownloader.Core.Platform.DesktopMediaMuxer.Default.ResolveFfmpeg(new VideoDownloader.Core.Config.AppConfig());
app.MapGet("/api/library", async (HttpContext ctx) =>
{
var items = MediaLibrary.List(options.LibraryFolder);
if (ffmpeg != null)
{
for (var i = 0; i < items.Count; i++)
{
var res = await thumbnailer.GetResolutionAsync(ffmpeg, Path.Combine(options.LibraryFolder, items[i].FileName));
items[i] = items[i] with { Resolution = res };
}
}
await ctx.Response.WriteAsJsonAsync(items);
});
app.MapGet("/api/library/{id}/thumb", async (string id) =>
{
var file = MediaLibrary.Find(options.LibraryFolder, id);
if (file == null || ffmpeg == null) return Results.NotFound();
var jpg = await thumbnailer.GetThumbnailAsync(ffmpeg, file.Value.FullPath);
return jpg == null ? Results.NotFound() : Results.File(jpg, "image/jpeg");
});
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);
});
// ── 이어보기 위치 동기화 (U3) ──
var positions = new PositionStore(options.LibraryFolder);
app.MapGet("/api/position/{id}", (string id) => Results.Json(new PositionDto(positions.Get(id))));
app.MapPost("/api/position/{id}", async (string id, HttpContext ctx) =>
{
var body = await ctx.Request.ReadFromJsonAsync<PositionDto>();
if (body == null) return Results.BadRequest();
positions.Set(id, body.PositionSec);
return Results.Ok();
});
// ── 실시간 큐 허브 (MOBILE_PLAN §3 /hubs/queue) ──
app.MapHub<QueueHub>("/hubs/queue");
var hubContext = app.Services.GetRequiredService<Microsoft.AspNetCore.SignalR.IHubContext<QueueHub>>();
Action onQueueChanged = () => _ = BroadcastQueueAsync(hubContext, queue);
queue.Changed += onQueueChanged;
// ── 웹 컴패니언 (제로 설치 클라이언트) ──
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;
}
// ── mDNS 광고 (_videodl._tcp) — 발견 보조. 실패는 MdnsError 로 드러내되 서버는 유지 ──
MdnsAdvertiser? mdns = null;
Exception? mdnsError = null;
if (options.EnableMdns)
{
try
{
mdns = new MdnsAdvertiser(options.DeviceName, actualPort);
mdns.Start();
}
catch (Exception ex)
{
mdnsError = ex;
mdns?.Dispose();
mdns = null;
}
}
return new ServerHost(app, actualPort, token, options, () => queue.Changed -= onQueueChanged, mdns, mdnsError);
}
/// <summary>큐 변경 → 전 클라이언트 푸시. 종료 경합만 무시(그 외 실패는 UnobservedTaskException 로 관찰됨).</summary>
private static async Task BroadcastQueueAsync(Microsoft.AspNetCore.SignalR.IHubContext<QueueHub> hub, IQueueAdapter queue)
{
try
{
var items = await queue.GetItemsAsync();
await hub.Clients.All.SendAsync("queueUpdated", items);
}
catch (ObjectDisposedException)
{
// 서버 종료와 경합한 마지막 푸시 — 클라이언트는 재접속/스냅샷으로 보정
}
}
public async ValueTask DisposeAsync()
{
_unsubscribeQueueChanged?.Invoke();
_mdns?.Dispose();
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);
}