V2 upgrade
This commit is contained in:
86
NostrStreamer/Controllers/LnurlController.cs
Normal file
86
NostrStreamer/Controllers/LnurlController.cs
Normal file
@ -0,0 +1,86 @@
|
||||
using BTCPayServer.Lightning;
|
||||
using LNURL;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using Nostr.Client.Json;
|
||||
using Nostr.Client.Messages;
|
||||
using NostrStreamer.Database;
|
||||
using NostrStreamer.Services;
|
||||
|
||||
namespace NostrStreamer.Controllers;
|
||||
|
||||
[Route("/api/pay")]
|
||||
public class LnurlController : Controller
|
||||
{
|
||||
private readonly Config _config;
|
||||
private readonly UserService _userService;
|
||||
|
||||
public LnurlController(Config config, UserService userService)
|
||||
{
|
||||
_config = config;
|
||||
_userService = userService;
|
||||
}
|
||||
|
||||
[HttpGet("/.well-known/lnurlp/{key}")]
|
||||
public async Task<IActionResult> GetPayService([FromRoute] string key)
|
||||
{
|
||||
var user = await _userService.GetUser(key);
|
||||
if (user == default) return LnurlError("User not found");
|
||||
|
||||
var metadata = GetMetadata(user);
|
||||
var pubKey = _config.GetPubKey();
|
||||
return Json(new LNURLPayRequest
|
||||
{
|
||||
Callback = new Uri(_config.ApiHost, $"/api/pay/{key}"),
|
||||
Metadata = JsonConvert.SerializeObject(metadata),
|
||||
MinSendable = LightMoney.Satoshis(1),
|
||||
MaxSendable = LightMoney.Coins(1),
|
||||
Tag = "payRequest",
|
||||
NostrPubkey = pubKey
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{key}")]
|
||||
public async Task<IActionResult> PayUserBalance([FromRoute] string key, [FromQuery] long amount, [FromQuery] string? nostr)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(nostr))
|
||||
{
|
||||
var ev = JsonConvert.DeserializeObject<NostrEvent>(nostr, NostrSerializer.Settings);
|
||||
if (ev?.Kind != NostrKind.ZapRequest || ev.Tags?.FindFirstTagValue("amount") != amount.ToString() ||
|
||||
!ev.IsSignatureValid())
|
||||
{
|
||||
throw new Exception("Invalid nostr event");
|
||||
}
|
||||
}
|
||||
|
||||
var invoice = await _userService.CreateTopup(key, (ulong)(amount / 1000), nostr);
|
||||
return Json(new LNURLPayRequest.LNURLPayRequestCallbackResponse
|
||||
{
|
||||
Pr = invoice
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return LnurlError($"Failed to create invoice (${ex.Message})");
|
||||
}
|
||||
}
|
||||
|
||||
private List<KeyValuePair<string, string>> GetMetadata(User u)
|
||||
{
|
||||
return new List<KeyValuePair<string, string>>()
|
||||
{
|
||||
new("text/plain", $"Topup for {u.PubKey}")
|
||||
};
|
||||
}
|
||||
|
||||
private IActionResult LnurlError(string reason)
|
||||
{
|
||||
return Json(new LNUrlStatusResponse()
|
||||
{
|
||||
Reason = reason,
|
||||
Status = "ERROR"
|
||||
});
|
||||
}
|
||||
}
|
@ -4,11 +4,11 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
using Nostr.Client.Json;
|
||||
using Nostr.Client.Messages;
|
||||
using Nostr.Client.Utils;
|
||||
using NostrStreamer.ApiModel;
|
||||
using NostrStreamer.Database;
|
||||
using NostrStreamer.Services;
|
||||
using NostrStreamer.Services.StreamManager;
|
||||
|
||||
namespace NostrStreamer.Controllers;
|
||||
|
||||
@ -18,14 +18,14 @@ public class NostrController : Controller
|
||||
{
|
||||
private readonly StreamerContext _db;
|
||||
private readonly Config _config;
|
||||
private readonly StreamManager _streamManager;
|
||||
private readonly StreamManagerFactory _streamManagerFactory;
|
||||
private readonly LndNode _lnd;
|
||||
|
||||
public NostrController(StreamerContext db, Config config, StreamManager streamManager, LndNode lnd)
|
||||
public NostrController(StreamerContext db, Config config, StreamManagerFactory streamManager, LndNode lnd)
|
||||
{
|
||||
_db = db;
|
||||
_config = config;
|
||||
_streamManager = streamManager;
|
||||
_streamManagerFactory = streamManager;
|
||||
_lnd = lnd;
|
||||
}
|
||||
|
||||
@ -48,18 +48,23 @@ public class NostrController : Controller
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var endpoints = await _db.Endpoints.ToListAsync();
|
||||
var account = new Account
|
||||
{
|
||||
Url = new Uri(_config.RtmpHost, _config.App).ToString(),
|
||||
Key = user.StreamKey,
|
||||
Event = !string.IsNullOrEmpty(user.Event) ? JsonConvert.DeserializeObject<NostrEvent>(user.Event, NostrSerializer.Settings) :
|
||||
null,
|
||||
Quota = new()
|
||||
Event = null,
|
||||
Endpoints = endpoints.Select(a => new AccountEndpoint()
|
||||
{
|
||||
Unit = "min",
|
||||
Rate = (int)Math.Ceiling(_config.Cost / 1000m),
|
||||
Remaining = (long)Math.Floor(user.Balance / 1000m)
|
||||
}
|
||||
Name = a.Name,
|
||||
Url = new Uri(_config.RtmpHost, a.App).ToString(),
|
||||
Key = user.StreamKey,
|
||||
Capabilities = a.Capabilities,
|
||||
Cost = new()
|
||||
{
|
||||
Unit = "min",
|
||||
Rate = a.Cost / 1000d
|
||||
}
|
||||
}).ToList(),
|
||||
Balance = (long)Math.Floor(user.Balance / 1000m)
|
||||
};
|
||||
|
||||
return Content(JsonConvert.SerializeObject(account, NostrSerializer.Settings), "application/json");
|
||||
@ -71,7 +76,8 @@ public class NostrController : Controller
|
||||
var pubkey = GetPubKey();
|
||||
if (string.IsNullOrEmpty(pubkey)) return Unauthorized();
|
||||
|
||||
await _streamManager.PatchEvent(pubkey, req.Title, req.Summary, req.Image, req.Tags, req.ContentWarning);
|
||||
var streamManager = await _streamManagerFactory.ForCurrentStream(pubkey);
|
||||
await streamManager.PatchEvent(req.Title, req.Summary, req.Image, req.Tags, req.ContentWarning);
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,8 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using NostrStreamer.Database;
|
||||
using NostrStreamer.Services;
|
||||
using NostrStreamer.Services.StreamManager;
|
||||
|
||||
namespace NostrStreamer.Controllers;
|
||||
|
||||
@ -11,36 +10,30 @@ namespace NostrStreamer.Controllers;
|
||||
public class PlaylistController : Controller
|
||||
{
|
||||
private readonly ILogger<PlaylistController> _logger;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly Config _config;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly HttpClient _client;
|
||||
private readonly SrsApi _srsApi;
|
||||
private readonly ViewCounter _viewCounter;
|
||||
private readonly StreamManagerFactory _streamManagerFactory;
|
||||
|
||||
public PlaylistController(Config config, IMemoryCache cache, ILogger<PlaylistController> logger, IServiceScopeFactory scopeFactory,
|
||||
HttpClient client, SrsApi srsApi, ViewCounter viewCounter)
|
||||
public PlaylistController(Config config, ILogger<PlaylistController> logger,
|
||||
HttpClient client, SrsApi srsApi, ViewCounter viewCounter, StreamManagerFactory streamManagerFactory)
|
||||
{
|
||||
_config = config;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
_client = client;
|
||||
_srsApi = srsApi;
|
||||
_viewCounter = viewCounter;
|
||||
_streamManagerFactory = streamManagerFactory;
|
||||
}
|
||||
|
||||
[HttpGet("{variant}/{pubkey}.m3u8")]
|
||||
public async Task RewritePlaylist([FromRoute] string pubkey, [FromRoute] string variant, [FromQuery(Name = "hls_ctx")] string hlsCtx)
|
||||
[HttpGet("{variant}/{id}.m3u8")]
|
||||
public async Task RewritePlaylist([FromRoute] Guid id, [FromRoute] string variant, [FromQuery(Name = "hls_ctx")] string hlsCtx)
|
||||
{
|
||||
var key = await GetStreamKey(pubkey);
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
var streamManager = await _streamManagerFactory.ForStream(id);
|
||||
var userStream = streamManager.GetStream();
|
||||
|
||||
var path = $"/{_config.App}/{variant}/{key}.m3u8";
|
||||
var path = $"/{userStream.Endpoint.App}/{variant}/{userStream.User.StreamKey}.m3u8";
|
||||
var ub = new UriBuilder(_config.SrsHttpHost)
|
||||
{
|
||||
Path = path,
|
||||
@ -66,8 +59,8 @@ public class PlaylistController : Controller
|
||||
{
|
||||
await sw.WriteLineAsync(line);
|
||||
var trackPath = await sr.ReadLineAsync();
|
||||
var seg = Regex.Match(trackPath!, @"-(\d+)\.ts$");
|
||||
await sw.WriteLineAsync($"{pubkey}/{seg.Groups[1].Value}.ts");
|
||||
var seg = Regex.Match(trackPath!, @"-(\d+)\.ts");
|
||||
await sw.WriteLineAsync($"{id}/{seg.Groups[1].Value}.ts");
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -76,71 +69,72 @@ public class PlaylistController : Controller
|
||||
}
|
||||
|
||||
Response.Body.Close();
|
||||
_viewCounter.Activity(key, hlsCtx);
|
||||
_viewCounter.Activity(userStream.Id, hlsCtx);
|
||||
}
|
||||
|
||||
[HttpGet("{pubkey}.m3u8")]
|
||||
public async Task CreateMultiBitrate([FromRoute] string pubkey)
|
||||
{
|
||||
var key = await GetStreamKey(pubkey);
|
||||
if (string.IsNullOrEmpty(key))
|
||||
try
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
var streamManager = await _streamManagerFactory.ForCurrentStream(pubkey);
|
||||
|
||||
var userStream = streamManager.GetStream();
|
||||
var hlsCtx = await GetHlsCtx(userStream);
|
||||
if (string.IsNullOrEmpty(hlsCtx))
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
Response.ContentType = "application/x-mpegurl";
|
||||
await using var sw = new StreamWriter(Response.Body);
|
||||
|
||||
var streams = await _srsApi.ListStreams();
|
||||
await sw.WriteLineAsync("#EXTM3U");
|
||||
|
||||
foreach (var variant in userStream.Endpoint.GetVariants().OrderBy(a => a.Bandwidth))
|
||||
{
|
||||
var stream = streams.FirstOrDefault(a =>
|
||||
a.Name == userStream.User.StreamKey && a.App == $"{userStream.Endpoint.App}/{variant.SourceName}");
|
||||
|
||||
var resArg = stream?.Video != default ? $"RESOLUTION={stream.Video?.Width}x{stream.Video?.Height}" :
|
||||
variant.ToResolutionArg();
|
||||
|
||||
var bandwidthArg = variant.ToBandwidthArg();
|
||||
|
||||
var averageBandwidthArg = stream?.Kbps?.Recv30s.HasValue ?? false ? $"AVERAGE-BANDWIDTH={stream.Kbps.Recv30s * 1000}" : "";
|
||||
var codecArg = "CODECS=\"avc1.640028,mp4a.40.2\"";
|
||||
var allArgs = new[] {bandwidthArg, averageBandwidthArg, resArg, codecArg}.Where(a => !string.IsNullOrEmpty(a));
|
||||
await sw.WriteLineAsync(
|
||||
$"#EXT-X-STREAM-INF:{string.Join(",", allArgs)}");
|
||||
|
||||
var u = new Uri(_config.DataHost,
|
||||
$"{variant.SourceName}/{userStream.Id}.m3u8{(!string.IsNullOrEmpty(hlsCtx) ? $"?hls_ctx={hlsCtx}" : "")}");
|
||||
|
||||
await sw.WriteLineAsync(u.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
var hlsCtx = await GetHlsCtx(key);
|
||||
if (string.IsNullOrEmpty(hlsCtx))
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get stream for {pubkey} {message}", pubkey, ex.Message);
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
Response.ContentType = "application/x-mpegurl";
|
||||
await using var sw = new StreamWriter(Response.Body);
|
||||
|
||||
var streams = await _srsApi.ListStreams();
|
||||
await sw.WriteLineAsync("#EXTM3U");
|
||||
|
||||
foreach (var variant in _config.Variants.OrderBy(a => a.Bandwidth))
|
||||
{
|
||||
var stream = streams.FirstOrDefault(a =>
|
||||
a.Name == key && a.App == $"{_config.App}/{variant.Name}");
|
||||
|
||||
var resArg = stream?.Video != default ? $"RESOLUTION={stream.Video?.Width}x{stream.Video?.Height}" :
|
||||
$"RESOLUTION={variant.Width}x{variant.Height}";
|
||||
|
||||
var bandwidthArg = $"BANDWIDTH={variant.Bandwidth * 1000}";
|
||||
|
||||
var averageBandwidthArg = stream?.Kbps?.Recv30s.HasValue ?? false ? $"AVERAGE-BANDWIDTH={stream.Kbps.Recv30s * 1000}" : "";
|
||||
var allArgs = new[] {bandwidthArg, averageBandwidthArg, resArg}.Where(a => !string.IsNullOrEmpty(a));
|
||||
await sw.WriteLineAsync(
|
||||
$"#EXT-X-STREAM-INF:{string.Join(",", allArgs)},CODECS=\"avc1.640028,mp4a.40.2\"");
|
||||
|
||||
var u = new Uri(_config.DataHost,
|
||||
$"{variant.Name}/{pubkey}.m3u8{(!string.IsNullOrEmpty(hlsCtx) ? $"?hls_ctx={hlsCtx}" : "")}");
|
||||
|
||||
await sw.WriteLineAsync(u.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{variant}/{pubkey}/{segment}")]
|
||||
public async Task GetSegment([FromRoute] string pubkey, [FromRoute] string segment, [FromRoute] string variant)
|
||||
[HttpGet("{variant}/{id}/{segment}")]
|
||||
public async Task GetSegment([FromRoute] Guid id, [FromRoute] string segment, [FromRoute] string variant)
|
||||
{
|
||||
var key = await GetStreamKey(pubkey);
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
var streamManager = await _streamManagerFactory.ForStream(id);
|
||||
var userStream = streamManager.GetStream();
|
||||
|
||||
var path = $"/{_config.App}/{variant}/{key}-{segment}";
|
||||
var path = $"/{userStream.Endpoint.App}/{variant}/{userStream.User.StreamKey}-{segment}";
|
||||
await ProxyRequest(path);
|
||||
}
|
||||
|
||||
private async Task<string?> GetHlsCtx(string key)
|
||||
private async Task<string?> GetHlsCtx(UserStream stream)
|
||||
{
|
||||
var path = $"/{_config.App}/source/{key}.m3u8";
|
||||
var path = $"/{stream.Endpoint.App}/source/{stream.User.StreamKey}.m3u8";
|
||||
var ub = new Uri(_config.SrsHttpHost, path);
|
||||
var req = CreateProxyRequest(ub);
|
||||
using var rsp = await _client.SendAsync(req);
|
||||
@ -186,21 +180,4 @@ public class PlaylistController : Controller
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
private async Task<string?> GetStreamKey(string pubkey)
|
||||
{
|
||||
var cacheKey = $"stream-key:{pubkey}";
|
||||
var cached = _cache.Get<string>(cacheKey);
|
||||
if (cached != default)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
await using var db = scope.ServiceProvider.GetRequiredService<StreamerContext>();
|
||||
var user = await db.Users.SingleOrDefaultAsync(a => a.PubKey == pubkey);
|
||||
|
||||
_cache.Set(cacheKey, user?.StreamKey);
|
||||
return user?.StreamKey;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Newtonsoft.Json;
|
||||
using NostrStreamer.Services;
|
||||
using NostrStreamer.Services.StreamManager;
|
||||
|
||||
namespace NostrStreamer.Controllers;
|
||||
|
||||
@ -8,14 +8,12 @@ namespace NostrStreamer.Controllers;
|
||||
public class SrsController : Controller
|
||||
{
|
||||
private readonly ILogger<SrsController> _logger;
|
||||
private readonly Config _config;
|
||||
private readonly StreamManager _streamManager;
|
||||
private readonly StreamManagerFactory _streamManagerFactory;
|
||||
|
||||
public SrsController(ILogger<SrsController> logger, Config config, StreamManager streamManager)
|
||||
public SrsController(ILogger<SrsController> logger, StreamManagerFactory streamManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config;
|
||||
_streamManager = streamManager;
|
||||
_streamManagerFactory = streamManager;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -24,8 +22,7 @@ public class SrsController : Controller
|
||||
_logger.LogInformation("OnStream: {obj}", JsonConvert.SerializeObject(req));
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(req.Stream) || string.IsNullOrEmpty(req.App) || string.IsNullOrEmpty(req.Stream) ||
|
||||
!req.App.StartsWith(_config.App, StringComparison.InvariantCultureIgnoreCase))
|
||||
if (string.IsNullOrEmpty(req.Stream) || string.IsNullOrEmpty(req.App))
|
||||
{
|
||||
return new()
|
||||
{
|
||||
@ -33,23 +30,44 @@ public class SrsController : Controller
|
||||
};
|
||||
}
|
||||
|
||||
var appSplit = req.App.Split("/");
|
||||
var streamManager = await _streamManagerFactory.ForStream(new StreamInfo
|
||||
{
|
||||
App = appSplit[0],
|
||||
Variant = appSplit.Length > 1 ? appSplit[1] : "source",
|
||||
ClientId = req.ClientId!,
|
||||
StreamKey = req.Stream
|
||||
});
|
||||
|
||||
if (req.Action == "on_forward")
|
||||
{
|
||||
var urls = await streamManager.OnForward();
|
||||
return new SrsForwardHookReply
|
||||
{
|
||||
Data = new()
|
||||
{
|
||||
Urls = urls
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (req.App.EndsWith("/source"))
|
||||
{
|
||||
if (req.Action == "on_publish")
|
||||
{
|
||||
await _streamManager.StreamStarted(req.Stream);
|
||||
await streamManager.StreamStarted();
|
||||
return new();
|
||||
}
|
||||
|
||||
if (req.Action == "on_unpublish")
|
||||
{
|
||||
await _streamManager.StreamStopped(req.Stream);
|
||||
await streamManager.StreamStopped();
|
||||
return new();
|
||||
}
|
||||
|
||||
if (req.Action == "on_hls" && req.Duration.HasValue && !string.IsNullOrEmpty(req.ClientId))
|
||||
{
|
||||
await _streamManager.ConsumeQuota(req.Stream, req.Duration.Value, req.ClientId);
|
||||
await streamManager.ConsumeQuota(req.Duration.Value);
|
||||
return new();
|
||||
}
|
||||
}
|
||||
@ -76,6 +94,18 @@ public class SrsHookReply
|
||||
public int Code { get; init; }
|
||||
}
|
||||
|
||||
public class SrsForwardHookReply : SrsHookReply
|
||||
{
|
||||
[JsonProperty("data")]
|
||||
public SrsUrlList Data { get; init; } = null!;
|
||||
}
|
||||
|
||||
public class SrsUrlList
|
||||
{
|
||||
[JsonProperty("urls")]
|
||||
public List<string> Urls { get; init; } = new();
|
||||
}
|
||||
|
||||
public class SrsHook
|
||||
{
|
||||
[JsonProperty("action")]
|
||||
|
Reference in New Issue
Block a user