245 lines
11 KiB
C#
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://<lan-ip>:<port>/#t=<token></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);
|
|
}
|