From 1fba6c3c37c0e7f94e9366947baa63f107e2f096 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 11 Jul 2023 16:08:57 +0100 Subject: [PATCH] Create variants --- NostrStreamer/Config.cs | 13 +++ .../Controllers/PlaylistController.cs | 90 +++++++++++++++--- NostrStreamer/Controllers/SRSController.cs | 4 +- NostrStreamer/Services/SrsApi.cs | 6 +- NostrStreamer/Services/StreamManager.cs | 3 +- NostrStreamer/appsettings.json | 28 +++++- docker/srs.conf | 92 +++++++++++++++++-- 7 files changed, 206 insertions(+), 30 deletions(-) diff --git a/NostrStreamer/Config.cs b/NostrStreamer/Config.cs index 45e596f..9c01ff0 100644 --- a/NostrStreamer/Config.cs +++ b/NostrStreamer/Config.cs @@ -41,6 +41,11 @@ public class Config /// Cost/min /// public int Cost { get; init; } = 10; + + /// + /// List of video variants + /// + public List Variants { get; init; } = null!; } public class LndConfig @@ -51,3 +56,11 @@ public class LndConfig public string MacaroonPath { get; init; } = null!; } + +public class Variant +{ + public string Name { get; init; } = null!; + public int Width { get; init; } + public int Height { get; init; } + public int Bandwidth { get; init; } +} diff --git a/NostrStreamer/Controllers/PlaylistController.cs b/NostrStreamer/Controllers/PlaylistController.cs index 4bb452d..d8be40f 100644 --- a/NostrStreamer/Controllers/PlaylistController.cs +++ b/NostrStreamer/Controllers/PlaylistController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using NostrStreamer.Database; +using NostrStreamer.Services; namespace NostrStreamer.Controllers; @@ -14,19 +15,21 @@ public class PlaylistController : Controller private readonly Config _config; private readonly IServiceScopeFactory _scopeFactory; private readonly HttpClient _client; + private readonly SrsApi _srsApi; public PlaylistController(Config config, IMemoryCache cache, ILogger logger, IServiceScopeFactory scopeFactory, - HttpClient client) + HttpClient client, SrsApi srsApi) { _config = config; _cache = cache; _logger = logger; _scopeFactory = scopeFactory; _client = client; + _srsApi = srsApi; } - [HttpGet("{pubkey}.m3u8")] - public async Task RewritePlaylist([FromRoute] string pubkey) + [HttpGet("{variant}/{pubkey}.m3u8")] + public async Task RewritePlaylist([FromRoute] string pubkey, [FromRoute] string variant) { var key = await GetStreamKey(pubkey); if (string.IsNullOrEmpty(key)) @@ -35,7 +38,7 @@ public class PlaylistController : Controller return; } - var path = $"/{_config.App}/{key}.m3u8"; + var path = $"/{_config.App}/{variant}/{key}.m3u8"; var ub = new UriBuilder(_config.SrsHttpHost) { Path = path, @@ -63,13 +66,6 @@ public class PlaylistController : Controller 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); @@ -79,8 +75,8 @@ public class PlaylistController : Controller Response.Body.Close(); } - [HttpGet("{pubkey}/{segment}")] - public async Task GetSegment([FromRoute] string pubkey, [FromRoute] string segment) + [HttpGet("{pubkey}.m3u8")] + public async Task CreateMultiBitrate([FromRoute] string pubkey) { var key = await GetStreamKey(pubkey); if (string.IsNullOrEmpty(key)) @@ -89,10 +85,76 @@ public class PlaylistController : Controller return; } - var path = $"/{_config.App}/{key}-{segment}"; + Response.ContentType = "application/x-mpegurl"; + await using var sw = new StreamWriter(Response.Body); + + + var streams = await _srsApi.ListStreams(); + await sw.WriteLineAsync("#EXTM3U"); + + var hlsCtx = await GetHlsCtx(key); + 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) + { + var key = await GetStreamKey(pubkey); + if (string.IsNullOrEmpty(key)) + { + Response.StatusCode = 404; + return; + } + + var path = $"/{_config.App}/{variant}/{key}-{segment}"; await ProxyRequest(path); } + private async Task GetHlsCtx(string key) + { + var path = $"/{_config.App}/{key}.m3u8"; + var ub = new Uri(_config.SrsHttpHost, path); + using var rsp = await _client.GetAsync(ub); + if (!rsp.IsSuccessStatusCode) + { + return default; + } + + using var sr = new StreamReader(await rsp.Content.ReadAsStreamAsync()); + while (await sr.ReadLineAsync() is { } line) + { + if (line.StartsWith("#EXT-X-STREAM-INF")) + { + var trackLine = await sr.ReadLineAsync(); + var rx = new Regex("\\?hls_ctx=(\\w+)$"); + var match = rx.Match(trackLine!); + if (match.Success) + { + return match.Groups[1].Value; + } + } + } + + return default; + } + private async Task ProxyRequest(string path) { using var rsp = await _client.GetAsync(new Uri(_config.SrsHttpHost, path)); diff --git a/NostrStreamer/Controllers/SRSController.cs b/NostrStreamer/Controllers/SRSController.cs index 339de58..55f78c6 100644 --- a/NostrStreamer/Controllers/SRSController.cs +++ b/NostrStreamer/Controllers/SRSController.cs @@ -25,7 +25,7 @@ public class SrsController : Controller try { if (string.IsNullOrEmpty(req.Stream) || string.IsNullOrEmpty(req.App) || string.IsNullOrEmpty(req.Stream) || - !req.App.Equals(_config.App, StringComparison.InvariantCultureIgnoreCase)) + !req.App.StartsWith(_config.App, StringComparison.InvariantCultureIgnoreCase)) { return new() { @@ -45,7 +45,7 @@ public class SrsController : Controller } 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.Stream, req.Duration.Value, req.ClientId, req.App); return new(); } } diff --git a/NostrStreamer/Services/SrsApi.cs b/NostrStreamer/Services/SrsApi.cs index c21a478..c33b840 100644 --- a/NostrStreamer/Services/SrsApi.cs +++ b/NostrStreamer/Services/SrsApi.cs @@ -20,7 +20,7 @@ public class SrsApi public async Task> ListClients() { - var rsp = await _client.GetFromJsonAsync("/api/v1/clients"); + var rsp = await _client.GetFromJsonAsync("/api/v1/clients/?count=10000"); return rsp!.Clients; } @@ -105,13 +105,13 @@ public class Stream public long? RecvBytes { get; set; } [JsonProperty("kbps")] - public Kbps Kbps { get; set; } + public Kbps? Kbps { get; set; } [JsonProperty("publish")] public Publish Publish { get; set; } [JsonProperty("video")] - public Video Video { get; set; } + public Video? Video { get; set; } [JsonProperty("audio")] public Audio Audio { get; set; } diff --git a/NostrStreamer/Services/StreamManager.cs b/NostrStreamer/Services/StreamManager.cs index d2ea7c1..61f0b24 100644 --- a/NostrStreamer/Services/StreamManager.cs +++ b/NostrStreamer/Services/StreamManager.cs @@ -56,8 +56,9 @@ public class StreamManager await PublishEvent(user, ev); } - public async Task ConsumeQuota(string streamKey, double duration, string clientId) + public async Task ConsumeQuota(string streamKey, double duration, string clientId, string app) { + if (!app.EndsWith("/source")) return; var user = await GetUserFromStreamKey(streamKey); if (user == default) throw new Exception("No stream key found"); diff --git a/NostrStreamer/appsettings.json b/NostrStreamer/appsettings.json index c069554..fdad06a 100644 --- a/NostrStreamer/appsettings.json +++ b/NostrStreamer/appsettings.json @@ -22,6 +22,32 @@ "Endpoint": "https://localhost:10002", "CertPath": "/Users/kieran/.polar/networks/1/volumes/lnd/bob/tls.cert", "MacaroonPath": "/Users/kieran/.polar/networks/1/volumes/lnd/bob/data/chain/bitcoin/regtest/admin.macaroon" - } + }, + "Variants": [ + { + "Name": "source", + "Width": 1920, + "Height": 1080, + "Bandwidth": 8000 + }, + { + "Name": "720p", + "Width": 1280, + "Height": 720, + "Bandwidth": 3000 + }, + { + "Name": "480p", + "Width": 854, + "Height": 480, + "Bandwidth": 1000 + }, + { + "Name": "240p", + "Width": 426, + "Height": 240, + "Bandwidth": 500 + } + ] } } diff --git a/docker/srs.conf b/docker/srs.conf index bb69e2c..85a94ce 100644 --- a/docker/srs.conf +++ b/docker/srs.conf @@ -15,18 +15,12 @@ rtc_server { listen 8000; candidate *; } -vhost __defaultVhost__ { +vhost transcode { hls { enabled on; hls_dispose 30; - hls_fragment 5; - hls_window 15; - } - http_hooks { - enabled on; - on_publish http://10.100.2.226:5295/api/srs; - on_unpublish http://10.100.2.226:5295/api/srs; - on_hls http://10.100.2.226:5295/api/srs; + hls_fragment 2; + hls_window 10; } rtc { enabled on; @@ -37,4 +31,84 @@ vhost __defaultVhost__ { enabled on; mount [vhost]/[app]/[stream].ts; } + http_hooks { + enabled on; + on_publish http://10.100.2.226:5295/api/srs; + on_unpublish http://10.100.2.226:5295/api/srs; + on_hls http://10.100.2.226:5295/api/srs; + } +} + +vhost __defaultVhost__ { + transcode { + enabled on; + ffmpeg ./objs/ffmpeg/bin/ffmpeg; + + engine source { + enabled on; + vcodec copy; + acodec copy; + output rtmp://127.0.0.1:[port]/[app]/[engine]/[stream]?vhost=transcode; + } + engine 720p { + enabled on; + vcodec libx264; + vbitrate 3000; + vfps 30; + vprofile baseline; + vpreset veryfast; + vfilter { + vf 'scale=-2:720'; + } + vparams { + g 60; + tune 'zerolatency'; + } + acodec libfdk_aac; + abitrate 160; + asample_rate 44100; + achannels 2; + output rtmp://127.0.0.1:[port]/[app]/[engine]/[stream]?vhost=transcode; + } + engine 480p { + enabled on; + vcodec libx264; + vbitrate 1000; + vfps 30; + vprofile baseline; + vpreset veryfast; + vfilter { + vf 'scale=-2:480'; + } + vparams { + g 60; + tune 'zerolatency'; + } + acodec libfdk_aac; + abitrate 96; + asample_rate 44100; + achannels 2; + output rtmp://127.0.0.1:[port]/[app]/[engine]/[stream]?vhost=transcode; + } + engine 240p { + enabled on; + vcodec libx264; + vbitrate 500; + vfps 30; + vprofile baseline; + vpreset veryfast; + vfilter { + vf 'scale=-2:240'; + } + vparams { + g 60; + tune 'zerolatency'; + } + acodec libfdk_aac; + abitrate 72; + asample_rate 44100; + achannels 2; + output rtmp://127.0.0.1:[port]/[app]/[engine]/[stream]?vhost=transcode; + } + } }