diff --git a/NostrStreamer/Config.cs b/NostrStreamer/Config.cs index d0b89eb..02227a4 100644 --- a/NostrStreamer/Config.cs +++ b/NostrStreamer/Config.cs @@ -2,10 +2,30 @@ namespace NostrStreamer; public class Config { - public Uri SrsPublicHost { get; init; } = null!; - public string App { get; init; } = null!; + /// + /// Ingest URL + /// + public Uri RtmpHost { get; init; } = null!; + + /// + /// SRS app name + /// + public string App { get; init; } = "live"; - public Uri SrsApi { get; init; } = null!; + /// + /// SRS api server host + /// + public Uri SrsApiHost { get; init; } = null!; + + /// + /// SRS Http server host + /// + public Uri SrsHttpHost { get; init; } = null!; + + /// + /// Public host where playlists are located + /// + public Uri DataHost { get; init; } = null!; public string PrivateKey { get; init; } = null!; public string[] Relays { get; init; } = Array.Empty(); diff --git a/NostrStreamer/Controllers/AccountController.cs b/NostrStreamer/Controllers/NostrController.cs similarity index 84% rename from NostrStreamer/Controllers/AccountController.cs rename to NostrStreamer/Controllers/NostrController.cs index 64ecbf1..a0e6d95 100644 --- a/NostrStreamer/Controllers/AccountController.cs +++ b/NostrStreamer/Controllers/NostrController.cs @@ -8,19 +8,19 @@ using NostrStreamer.Database; namespace NostrStreamer.Controllers; [Authorize] -[Route("/api/account")] -public class AccountController : Controller +[Route("/api/nostr")] +public class NostrController : Controller { private readonly StreamerContext _db; private readonly Config _config; - public AccountController(StreamerContext db, Config config) + public NostrController(StreamerContext db, Config config) { _db = db; _config = config; } - [HttpGet] + [HttpGet("account")] public async Task GetAccount() { var user = await GetUser(); @@ -41,7 +41,7 @@ public class AccountController : Controller return Json(new Account { - Url = $"rtmp://{_config.SrsPublicHost.Host}/${_config.App}", + Url = new Uri(_config.RtmpHost, _config.App).ToString(), Key = user.StreamKey }); } diff --git a/NostrStreamer/Controllers/PlaylistController.cs b/NostrStreamer/Controllers/PlaylistController.cs new file mode 100644 index 0000000..4bb452d --- /dev/null +++ b/NostrStreamer/Controllers/PlaylistController.cs @@ -0,0 +1,120 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using NostrStreamer.Database; + +namespace NostrStreamer.Controllers; + +[Route("/api/playlist")] +public class PlaylistController : Controller +{ + private readonly ILogger _logger; + private readonly IMemoryCache _cache; + private readonly Config _config; + private readonly IServiceScopeFactory _scopeFactory; + private readonly HttpClient _client; + + public PlaylistController(Config config, IMemoryCache cache, ILogger logger, IServiceScopeFactory scopeFactory, + HttpClient client) + { + _config = config; + _cache = cache; + _logger = logger; + _scopeFactory = scopeFactory; + _client = client; + } + + [HttpGet("{pubkey}.m3u8")] + public async Task RewritePlaylist([FromRoute] string pubkey) + { + var key = await GetStreamKey(pubkey); + if (string.IsNullOrEmpty(key)) + { + Response.StatusCode = 404; + return; + } + + var path = $"/{_config.App}/{key}.m3u8"; + var ub = new UriBuilder(_config.SrsHttpHost) + { + Path = path, + Query = string.Join("&", Request.Query.Select(a => $"{a.Key}={a.Value}")) + }; + + Response.ContentType = "application/x-mpegurl"; + await using var sw = new StreamWriter(Response.Body); + + using var rsp = await _client.GetAsync(ub.Uri); + if (!rsp.IsSuccessStatusCode) + { + Response.StatusCode = (int)rsp.StatusCode; + return; + } + + await Response.StartAsync(); + using var sr = new StreamReader(await rsp.Content.ReadAsStreamAsync()); + while (await sr.ReadLineAsync() is { } line) + { + if (line.StartsWith("#EXTINF")) + { + 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"); + } + else if (line.StartsWith("#EXT-X-STREAM-INF")) + { + await sw.WriteLineAsync(line); + var trackPath = await sr.ReadLineAsync(); + var trackUri = new Uri(_config.SrsHttpHost, trackPath!); + await sw.WriteLineAsync($"{pubkey}.m3u8{trackUri.Query}"); + } + else + { + await sw.WriteLineAsync(line); + } + } + + Response.Body.Close(); + } + + [HttpGet("{pubkey}/{segment}")] + public async Task GetSegment([FromRoute] string pubkey, [FromRoute] string segment) + { + var key = await GetStreamKey(pubkey); + if (string.IsNullOrEmpty(key)) + { + Response.StatusCode = 404; + return; + } + + var path = $"/{_config.App}/{key}-{segment}"; + await ProxyRequest(path); + } + + private async Task ProxyRequest(string path) + { + using var rsp = await _client.GetAsync(new Uri(_config.SrsHttpHost, path)); + Response.Headers.ContentType = rsp.Content.Headers.ContentType?.ToString(); + + await rsp.Content.CopyToAsync(Response.Body); + } + + private async Task GetStreamKey(string pubkey) + { + var cacheKey = $"stream-key:{pubkey}"; + var cached = _cache.Get(cacheKey); + if (cached != default) + { + return cached; + } + + using var scope = _scopeFactory.CreateScope(); + await using var db = scope.ServiceProvider.GetRequiredService(); + var user = await db.Users.SingleOrDefaultAsync(a => a.PubKey == pubkey); + + _cache.Set(cacheKey, user?.StreamKey); + return user?.StreamKey; + } +} diff --git a/NostrStreamer/Program.cs b/NostrStreamer/Program.cs index 374dd18..44e1311 100644 --- a/NostrStreamer/Program.cs +++ b/NostrStreamer/Program.cs @@ -15,6 +15,9 @@ internal static class Program var config = builder.Configuration.GetSection("Config").Get(); ConfigureDb(services, builder.Configuration); + services.AddCors(); + services.AddMemoryCache(); + services.AddHttpClient(); services.AddControllers(); services.AddSingleton(config); @@ -34,7 +37,8 @@ internal static class Program var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); } - + + app.UseCors(o => o.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin()); app.MapControllers(); await app.RunAsync(); diff --git a/NostrStreamer/Services/StreamManager.cs b/NostrStreamer/Services/StreamManager.cs index 4853046..093693c 100644 --- a/NostrStreamer/Services/StreamManager.cs +++ b/NostrStreamer/Services/StreamManager.cs @@ -97,12 +97,8 @@ public class StreamManager private string GetStreamUrl(User u) { - var ub = new UriBuilder(_config.SrsPublicHost) - { - Path = $"/{_config.App}/${u.StreamKey}.m3u8" - }; - - return ub.Uri.ToString(); + var ub = new Uri(_config.DataHost, $"{u.PubKey}.m3u8"); + return ub.ToString(); } private async Task GetUserFromStreamKey(string streamKey) diff --git a/NostrStreamer/appsettings.json b/NostrStreamer/appsettings.json index f633289..d8c7420 100644 --- a/NostrStreamer/appsettings.json +++ b/NostrStreamer/appsettings.json @@ -11,8 +11,10 @@ "Database": "User ID=postgres;Password=postgres;Database=streaming;Pooling=true;Host=127.0.0.1:5431" }, "Config": { - "SrsPublicHost": "http://localhost:8080", - "SrsApi": "http://localhost:1985", + "RtmpHost": "rtmp://localhost:1935", + "SrsHttpHost": "http://localhost:8080", + "SrsApiHost": "http://localhost:1985", + "DataHost": "http://localhost:5295/api/playlist/", "App": "test", "Relays": ["ws://localhost:8081"], "PrivateKey": "nsec1yqtv8s8y9krh6l8pwp09lk2jkulr9e0klu95tlk7dgus9cklr4ssdv3d88" diff --git a/docker/srs.conf b/docker/srs.conf index 1343e7a..713748e 100644 --- a/docker/srs.conf +++ b/docker/srs.conf @@ -14,6 +14,8 @@ vhost __defaultVhost__ { hls { enabled on; hls_dispose 30; + hls_fragment 5; + hls_window 15; } http_hooks { enabled on;