This commit is contained in:
2023-06-30 14:08:15 +01:00
commit bcaa32afb1
28 changed files with 1109 additions and 0 deletions

25
.dockerignore Normal file
View File

@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

19
.drone.yml Normal file
View File

@ -0,0 +1,19 @@
---
kind: pipeline
type: kubernetes
name: default
metadata:
namespace: git
steps:
- name: build
image: r.j3ss.co/img
privileged: true
environment:
TOKEN:
from_secret: registry_token
commands:
- img login -u registry -p $TOKEN registry.v0l.io
- cd NostrStreamer && img build -t registry.v0l.io/nostr-streamer:latest .
- img push registry.v0l.io/nostr-streamer:latest

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
bin/
obj/
.idea/

16
NostrStreamer.sln Normal file
View File

@ -0,0 +1,16 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NostrStreamer", "NostrStreamer\NostrStreamer.csproj", "{883F6FF4-B1BA-48F7-82BF-9A0851051006}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{883F6FF4-B1BA-48F7-82BF-9A0851051006}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{883F6FF4-B1BA-48F7-82BF-9A0851051006}.Debug|Any CPU.Build.0 = Debug|Any CPU
{883F6FF4-B1BA-48F7-82BF-9A0851051006}.Release|Any CPU.ActiveCfg = Release|Any CPU
{883F6FF4-B1BA-48F7-82BF-9A0851051006}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@ -0,0 +1,12 @@
using Newtonsoft.Json;
namespace NostrStreamer.ApiModel;
public class Account
{
[JsonProperty("url")]
public string Url { get; init; } = null!;
[JsonProperty("key")]
public string Key { get; init; } = null!;
}

12
NostrStreamer/Config.cs Normal file
View File

@ -0,0 +1,12 @@
namespace NostrStreamer;
public class Config
{
public Uri SrsPublicHost { get; init; } = null!;
public string App { get; init; } = null!;
public Uri SrsApi { get; init; } = null!;
public string PrivateKey { get; init; } = null!;
public string[] Relays { get; init; } = Array.Empty<string>();
}

View File

@ -0,0 +1,60 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NostrStreamer.ApiModel;
using NostrStreamer.Database;
namespace NostrStreamer.Controllers;
[Authorize]
[Route("/api/account")]
public class AccountController : Controller
{
private readonly StreamerContext _db;
private readonly Config _config;
public AccountController(StreamerContext db, Config config)
{
_db = db;
_config = config;
}
[HttpGet]
public async Task<ActionResult> GetAccount()
{
var user = await GetUser();
if (user == default)
{
var pk = GetPubKey();
user = new()
{
PubKey = pk,
Balance = 0,
StreamKey = Guid.NewGuid().ToString()
};
_db.Users.Add(user);
await _db.SaveChangesAsync();
}
return Json(new Account
{
Url = $"rtmp://{_config.SrsPublicHost.Host}/${_config.App}",
Key = user.StreamKey
});
}
private async Task<User?> GetUser()
{
var pk = GetPubKey();
return await _db.Users.FirstOrDefaultAsync(a => a.PubKey == pk);
}
private string GetPubKey()
{
var claim = HttpContext.User.Claims.FirstOrDefault(a => a.Type == ClaimTypes.Name);
return claim!.Value;
}
}

View File

@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using NostrStreamer.Services;
namespace NostrStreamer.Controllers;
[Route("/api/srs")]
public class SrsController : Controller
{
private readonly ILogger<SrsController> _logger;
private readonly Config _config;
private readonly StreamManager _streamManager;
public SrsController(ILogger<SrsController> logger, Config config, StreamManager streamManager)
{
_logger = logger;
_config = config;
_streamManager = streamManager;
}
[HttpPost]
public async Task<SrsHookReply> OnStream([FromBody] SrsHook req)
{
_logger.LogInformation("OnStream: {obj}", JsonConvert.SerializeObject(req));
try
{
if (string.IsNullOrEmpty(req.Stream) || string.IsNullOrEmpty(req.App) || string.IsNullOrEmpty(req.Stream) ||
!req.App.Equals(_config.App, StringComparison.InvariantCultureIgnoreCase))
{
return new()
{
Code = 2 // invalid request
};
}
if (req.Action == "on_publish")
{
await _streamManager.StreamStarted(req.Stream);
return new();
}
if (req.Action == "on_unpublish")
{
await _streamManager.StreamStopped(req.Stream);
return new();
}
if (req.Action == "on_hls" && req.Duration.HasValue)
{
await _streamManager.ConsumeQuota(req.Stream, req.Duration.Value);
return new();
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to start stream");
}
return new()
{
Code = 1 // generic error
};
}
}
public class SrsHookReply
{
[JsonProperty("code")]
public int Code { get; init; }
}
public class SrsHook
{
[JsonProperty("action")]
public string? Action { get; set; }
[JsonProperty("client_id")]
public string? ClientId { get; set; }
[JsonProperty("ip")]
public string? Ip { get; set; }
[JsonProperty("vhost")]
public string? Vhost { get; set; }
[JsonProperty("app")]
public string? App { get; set; }
[JsonProperty("stream")]
public string? Stream { get; set; }
[JsonProperty("param")]
public string? Param { get; init; }
[JsonProperty("duration")]
public double? Duration { get; init; }
}

View File

@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace NostrStreamer.Database.Configuration;
public class PaymentsConfiguration : IEntityTypeConfiguration<Payment>
{
public void Configure(EntityTypeBuilder<Payment> builder)
{
builder.HasKey(a => a.PubKey);
builder.Property(a => a.Invoice)
.IsRequired();
builder.Property(a => a.IsPaid)
.IsRequired();
}
}

View File

@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace NostrStreamer.Database.Configuration;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.HasKey(a => a.PubKey);
builder.Property(a => a.StreamKey)
.IsRequired();
builder.Property(a => a.Event);
builder.Property(a => a.Balance)
.IsRequired();
}
}

View File

@ -0,0 +1,10 @@
namespace NostrStreamer.Database;
public class Payment
{
public string PubKey { get; init; } = null!;
public string Invoice { get; init; } = null!;
public bool IsPaid { get; init; }
}

View File

@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
namespace NostrStreamer.Database;
public class StreamerContext : DbContext
{
public StreamerContext()
{
}
public StreamerContext(DbContextOptions<StreamerContext> ctx) : base(ctx)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(StreamerContext).Assembly);
}
public DbSet<User> Users => Set<User>();
public DbSet<Payment> Payments => Set<Payment>();
}

View File

@ -0,0 +1,41 @@
namespace NostrStreamer.Database;
public class User
{
public string PubKey { get; init; } = null!;
/// <summary>
/// Stream key
/// </summary>
public string StreamKey { get; init; } = null!;
/// <summary>
/// Most recent nostr event published
/// </summary>
public string? Event { get; init; }
/// <summary>
/// Sats balance
/// </summary>
public long Balance { get; init; }
/// <summary>
/// Stream title
/// </summary>
public string? Title { get; init; }
/// <summary>
/// Stream summary
/// </summary>
public string? Summary { get; init; }
/// <summary>
/// Stream cover image
/// </summary>
public string? Image { get; init; }
/// <summary>
/// Comma seperated tags
/// </summary>
public string? Tags { get; init; }
}

20
NostrStreamer/Dockerfile Normal file
View File

@ -0,0 +1,20 @@
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["NostrStreamer/NostrStreamer.csproj", "NostrStreamer/"]
RUN dotnet restore "NostrStreamer/NostrStreamer.csproj"
COPY . .
WORKDIR "/src/NostrStreamer"
RUN dotnet build "NostrStreamer.csproj" -c Release -o /app/build
FROM build AS publish
RUN dotnet publish "NostrStreamer.csproj" -c Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "NostrStreamer.dll"]

View File

@ -0,0 +1,8 @@
using Nostr.Client.Messages;
using NostrStreamer.Database;
namespace NostrStreamer;
public static class Extensions
{
}

View File

@ -0,0 +1,78 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NostrStreamer.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NostrStreamer.Migrations
{
[DbContext(typeof(StreamerContext))]
[Migration("20230630094322_Init")]
partial class Init
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("NostrStreamer.Database.Payment", b =>
{
b.Property<string>("PubKey")
.HasColumnType("text");
b.Property<string>("Invoice")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsPaid")
.HasColumnType("boolean");
b.HasKey("PubKey");
b.ToTable("Payments");
});
modelBuilder.Entity("NostrStreamer.Database.User", b =>
{
b.Property<string>("PubKey")
.HasColumnType("text");
b.Property<long>("Balance")
.HasColumnType("bigint");
b.Property<string>("Event")
.HasColumnType("text");
b.Property<string>("Image")
.HasColumnType("text");
b.Property<string>("StreamKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Summary")
.HasColumnType("text");
b.Property<string>("Tags")
.HasColumnType("text");
b.Property<string>("Title")
.HasColumnType("text");
b.HasKey("PubKey");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NostrStreamer.Migrations
{
/// <inheritdoc />
public partial class Init : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Payments",
columns: table => new
{
PubKey = table.Column<string>(type: "text", nullable: false),
Invoice = table.Column<string>(type: "text", nullable: false),
IsPaid = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Payments", x => x.PubKey);
});
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
PubKey = table.Column<string>(type: "text", nullable: false),
StreamKey = table.Column<string>(type: "text", nullable: false),
Event = table.Column<string>(type: "text", nullable: true),
Balance = table.Column<long>(type: "bigint", nullable: false),
Title = table.Column<string>(type: "text", nullable: true),
Summary = table.Column<string>(type: "text", nullable: true),
Image = table.Column<string>(type: "text", nullable: true),
Tags = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Users", x => x.PubKey);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Payments");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@ -0,0 +1,75 @@
// <auto-generated />
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NostrStreamer.Database;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace NostrStreamer.Migrations
{
[DbContext(typeof(StreamerContext))]
partial class StreamerContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "7.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("NostrStreamer.Database.Payment", b =>
{
b.Property<string>("PubKey")
.HasColumnType("text");
b.Property<string>("Invoice")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("IsPaid")
.HasColumnType("boolean");
b.HasKey("PubKey");
b.ToTable("Payments");
});
modelBuilder.Entity("NostrStreamer.Database.User", b =>
{
b.Property<string>("PubKey")
.HasColumnType("text");
b.Property<long>("Balance")
.HasColumnType("bigint");
b.Property<string>("Event")
.HasColumnType("text");
b.Property<string>("Image")
.HasColumnType("text");
b.Property<string>("StreamKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Summary")
.HasColumnType("text");
b.Property<string>("Tags")
.HasColumnType("text");
b.Property<string>("Title")
.HasColumnType("text");
b.HasKey("PubKey");
b.ToTable("Users");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,90 @@
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Nostr.Client.Messages;
namespace NostrStreamer;
public static class NostrAuth
{
public const string Scheme = "Nostr";
}
public class NostrAuthOptions : AuthenticationSchemeOptions
{
}
public class NostrAuthHandler : AuthenticationHandler<NostrAuthOptions>
{
public NostrAuthHandler(IOptionsMonitor<NostrAuthOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock) :
base(options, logger, encoder, clock)
{
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var auth = Request.Headers.Authorization.FirstOrDefault()?.Trim();
if (string.IsNullOrEmpty(auth))
{
return AuthenticateResult.Fail("Missing Authorization header");
}
if (!auth.StartsWith(NostrAuth.Scheme))
{
return AuthenticateResult.Fail("Invalid auth scheme");
}
var token = auth[6..];
var bToken = Convert.FromBase64String(token);
if (string.IsNullOrEmpty(token) || bToken.Length == 0 || bToken[0] != '{')
{
return AuthenticateResult.Fail("Invalid token");
}
var ev = JsonConvert.DeserializeObject<NostrEvent>(Encoding.UTF8.GetString(bToken));
if (ev == default)
{
return AuthenticateResult.Fail("Invalid nostr event");
}
if (!ev.IsSignatureValid())
{
return AuthenticateResult.Fail("Invalid nostr event, invalid sig");
}
if (ev.Kind != (NostrKind)27_235)
{
return AuthenticateResult.Fail("Invalid nostr event, wrong kind");
}
var diffTime = Math.Abs((ev.CreatedAt!.Value - DateTime.UtcNow).TotalSeconds);
if (diffTime > 60d)
{
return AuthenticateResult.Fail("Invalid nostr event, timestamp out of range");
}
var urlTag = ev.Tags!.FirstOrDefault(a => a.TagIdentifier == "url");
var methodTag = ev.Tags!.FirstOrDefault(a => a.TagIdentifier == "method");
if (string.IsNullOrEmpty(urlTag?.AdditionalData[0] as string) ||
!new Uri((urlTag.AdditionalData[0] as string)!).AbsolutePath.Equals(Request.Path, StringComparison.InvariantCultureIgnoreCase))
{
return AuthenticateResult.Fail("Invalid nostr event, url tag invalid");
}
if (string.IsNullOrEmpty(methodTag?.AdditionalData[0] as string) ||
!((methodTag.AdditionalData[0] as string)?.Equals(Request.Method, StringComparison.InvariantCultureIgnoreCase) ?? false))
{
return AuthenticateResult.Fail("Invalid nostr event, method tag invalid");
}
var principal = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, ev.Pubkey!)
});
return AuthenticateResult.Success(new(new ClaimsPrincipal(new[] {principal}), Scheme.Name));
}
}

View File

@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
<Content Include="..\.drone.yml">
<Link>.drone.yml</Link>
</Content>
<Content Include="..\docker-compose.yaml">
<Link>docker-compose.yaml</Link>
</Content>
<Content Include="..\docker\srs.conf">
<Link>srs.conf</Link>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Nostr.Client" Version="1.4.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="7.0.4" />
</ItemGroup>
</Project>

61
NostrStreamer/Program.cs Normal file
View File

@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Nostr.Client.Client;
using NostrStreamer.Database;
using NostrStreamer.Services;
namespace NostrStreamer;
internal static class Program
{
public static async Task Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
var config = builder.Configuration.GetSection("Config").Get<Config>();
ConfigureDb(services, builder.Configuration);
services.AddControllers();
services.AddSingleton(config);
// nostr services
services.AddSingleton<NostrMultiWebsocketClient>();
services.AddSingleton<INostrClient>(s => s.GetRequiredService<NostrMultiWebsocketClient>());
services.AddSingleton<NostrListener>();
services.AddHostedService<NostrListenerLifetime>();
// streaming services
services.AddTransient<StreamManager>();
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<StreamerContext>();
await db.Database.MigrateAsync();
}
app.MapControllers();
await app.RunAsync();
}
private static void ConfigureDb(IServiceCollection services, IConfiguration configuration)
{
services.AddDbContext<StreamerContext>(o => o.UseNpgsql(configuration.GetConnectionString("Database")));
}
/// <summary>
/// Dummy method for EF core migrations
/// </summary>
/// <param name="args"></param>
/// <returns></returns>
// ReSharper disable once UnusedMember.Global
public static IHostBuilder CreateHostBuilder(string[] args)
{
var dummyHost = Host.CreateDefaultBuilder(args);
dummyHost.ConfigureServices((ctx, svc) => { ConfigureDb(svc, ctx.Configuration); });
return dummyHost;
}
}

View File

@ -0,0 +1,13 @@
{
"profiles": {
"NostrStreamer": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://*:5295",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@ -0,0 +1,137 @@
using System.Net.WebSockets;
using System.Reflection;
using Nostr.Client.Client;
using Nostr.Client.Communicator;
using Nostr.Client.Requests;
using Websocket.Client.Models;
namespace NostrStreamer.Services;
public class NostrListener : IDisposable
{
private readonly Config _config;
private readonly NostrMultiWebsocketClient _client;
private readonly INostrCommunicator[] _communicators;
private readonly ILogger<NostrListener> _logger;
private readonly Dictionary<string, NostrFilter> _subscriptionToFilter = new();
public NostrListener(Config config, NostrMultiWebsocketClient client, ILogger<NostrListener> logger)
{
_config = config;
_client = client;
_logger = logger;
_communicators = CreateCommunicators();
foreach (var communicator in _communicators)
_client.RegisterCommunicator(communicator);
}
public NostrClientStreams Streams => _client.Streams;
public void Dispose()
{
_client.Dispose();
foreach (var comm in _communicators)
{
comm.Dispose();
}
}
public void RegisterFilter(string subscription, NostrFilter filter)
{
_subscriptionToFilter[subscription] = filter;
}
public void Start()
{
foreach (var comm in _communicators)
{
// fire and forget
_ = comm.Start();
}
}
public void Stop()
{
foreach (var comm in _communicators)
{
// fire and forget
_ = comm.Stop(WebSocketCloseStatus.NormalClosure, string.Empty);
}
}
private INostrCommunicator[] CreateCommunicators() =>
_config.Relays
.Select(x => CreateCommunicator(new Uri(x)))
.ToArray();
private INostrCommunicator CreateCommunicator(Uri uri)
{
var comm = new NostrWebsocketCommunicator(uri, () =>
{
var client = new ClientWebSocket();
client.Options.SetRequestHeader("Origin", "http://localhost");
client.Options.SetRequestHeader("User-Agent", $"NostrStreamer ({Assembly.GetExecutingAssembly().GetName().Version})");
return client;
});
comm.Name = uri.Host;
comm.ReconnectTimeout = null; //TimeSpan.FromSeconds(30);
comm.ErrorReconnectTimeout = TimeSpan.FromSeconds(60);
comm.ReconnectionHappened.Subscribe(info => OnCommunicatorReconnection(info, comm.Name));
comm.DisconnectionHappened.Subscribe(info =>
_logger.LogWarning("[{relay}] Disconnected, type: {type}, reason: {reason}", comm.Name, info.Type, info.CloseStatus));
return comm;
}
private void OnCommunicatorReconnection(ReconnectionInfo info, string communicatorName)
{
try
{
_logger.LogInformation("[{relay}] Reconnected, sending Nostr filters ({filterCount})", communicatorName,
_subscriptionToFilter.Count);
var client = _client.FindClient(communicatorName);
if (client == null)
{
_logger.LogWarning("[{relay}] Cannot find client", communicatorName);
return;
}
foreach (var (sub, filter) in _subscriptionToFilter)
{
client.Send(new NostrRequest(sub, filter));
}
}
catch (Exception e)
{
_logger.LogError(e, "[{relay}] Failed to process reconnection, error: {error}", communicatorName, e.Message);
}
}
}
public class NostrListenerLifetime : IHostedService
{
private readonly NostrListener _nostrListener;
public NostrListenerLifetime(NostrListener nostrListener)
{
_nostrListener = nostrListener;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_nostrListener.Start();
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
_nostrListener.Dispose();
return Task.CompletedTask;
}
}

View File

@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore;
using Nostr.Client.Client;
using Nostr.Client.Keys;
using Nostr.Client.Messages;
using Nostr.Client.Requests;
using NostrStreamer.Database;
namespace NostrStreamer.Services;
public class StreamManager
{
private readonly ILogger<StreamManager> _logger;
private readonly StreamerContext _db;
private readonly Config _config;
private readonly INostrClient _nostr;
public StreamManager(ILogger<StreamManager> logger, StreamerContext db, Config config, INostrClient nostr)
{
_logger = logger;
_db = db;
_config = config;
_nostr = nostr;
}
public async Task StreamStarted(string streamKey)
{
var user = await GetUserFromStreamKey(streamKey);
if (user == default) throw new Exception("No stream key found");
_logger.LogInformation("Stream started for: {pubkey}", user.PubKey);
if (user.Balance <= 0)
{
throw new Exception("User balance empty");
}
var ev = CreateStreamEvent(user, "live");
_nostr.Send(new NostrEventRequest(ev));
}
public async Task StreamStopped(string streamKey)
{
var user = await GetUserFromStreamKey(streamKey);
if (user == default) throw new Exception("No stream key found");
_logger.LogInformation("Stream stopped for: {pubkey}", user.PubKey);
var ev = CreateStreamEvent(user, "ended");
_nostr.Send(new NostrEventRequest(ev));
}
public async Task ConsumeQuota(string streamKey, double duration)
{
var user = await GetUserFromStreamKey(streamKey);
if (user == default) throw new Exception("No stream key found");
const double rate = 21.0d;
var cost = Math.Round(duration / 60d * rate);
await _db.Users
.Where(a => a.PubKey == user.PubKey)
.ExecuteUpdateAsync(o => o.SetProperty(v => v.Balance, v => v.Balance - cost));
_logger.LogInformation("Stream consumed {n} seconds for {pubkey} costing {cost} sats", duration, user.PubKey, cost);
if (user.Balance <= 0)
{
throw new Exception("User balance empty");
}
}
private NostrEvent CreateStreamEvent(User user, string state)
{
var tags = new List<NostrEventTag>()
{
new("d", user.PubKey),
new("title", user.Title ?? ""),
new("summary", user.Summary ?? ""),
new("streaming", GetStreamUrl(user)),
new("image", user.Image ?? ""),
new("status", state),
new("p", user.PubKey, "", "host")
};
foreach (var tag in user.Tags?.Split(",") ?? Array.Empty<string>())
{
tags.Add(new("t", tag.Trim()));
}
var ev = new NostrEvent
{
Kind = (NostrKind)30_311,
Content = "",
CreatedAt = DateTime.Now,
Tags = new NostrEventTags(tags)
};
return ev.Sign(NostrPrivateKey.FromBech32(_config.PrivateKey));
}
private string GetStreamUrl(User u)
{
var ub = new UriBuilder(_config.SrsPublicHost)
{
Path = $"/{_config.App}/${u.StreamKey}.m3u8"
};
return ub.Uri.ToString();
}
private async Task<User?> GetUserFromStreamKey(string streamKey)
{
return await _db.Users.SingleOrDefaultAsync(a => a.StreamKey == streamKey);
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,20 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"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",
"App": "test",
"Relays": ["ws://localhost:8081"],
"PrivateKey": "nsec1yqtv8s8y9krh6l8pwp09lk2jkulr9e0klu95tlk7dgus9cklr4ssdv3d88"
}
}

19
docker-compose.yaml Normal file
View File

@ -0,0 +1,19 @@
services:
srs:
image: ossrs/srs
volumes:
- "./docker/srs.conf:/usr/local/srs/conf/docker.conf"
ports:
- "1935:1935"
- "1985:1985"
- "8080:8080"
nostr:
image: scsibug/nostr-rs-relay
ports:
- "8081:8080"
postgres:
image: postgres:15
environment:
- "POSTGRES_HOST_AUTH_METHOD=trust"
ports:
- "5431:5432"

24
docker/srs.conf Normal file
View File

@ -0,0 +1,24 @@
listen 1935;
max_connections 1000;
daemon off;
srs_log_tank console;
http_api {
enabled on;
listen 1985;
}
http_server {
enabled on;
listen 8080;
}
vhost __defaultVhost__ {
hls {
enabled on;
hls_dispose 30;
}
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;
}
}