From 97e43c8cbdb830b88392fde0c52c68662e35b205 Mon Sep 17 00:00:00 2001 From: kieran Date: Wed, 28 Aug 2024 10:02:49 +0100 Subject: [PATCH] feat: stream-keys --- NostrStreamer/ApiModel/Account.cs | 3 - .../ApiModel/CreateStreamKeyRequest.cs | 8 + NostrStreamer/ApiModel/PatchEvent.cs | 3 + NostrStreamer/Controllers/NostrController.cs | 101 +++- .../Controllers/PlaylistController.cs | 8 +- .../Controllers/PodcastController.cs | 4 +- .../Configuration/UserStreamConfiguration.cs | 9 +- .../UserStreamKeyConfiguration.cs | 31 + NostrStreamer/Database/StreamerContext.cs | 2 + NostrStreamer/Database/User.cs | 3 +- NostrStreamer/Database/UserStream.cs | 39 +- NostrStreamer/Database/UserStreamKey.cs | 25 + NostrStreamer/Extensions.cs | 48 +- .../20240821112332_StreamKeys.Designer.cs | 558 ++++++++++++++++++ .../Migrations/20240821112332_StreamKeys.cs | 61 ++ ...240821132917_MigrateStreamInfo.Designer.cs | 557 +++++++++++++++++ .../20240821132917_MigrateStreamInfo.cs | 149 +++++ .../StreamerContextModelSnapshot.cs | 74 ++- .../Services/Background/LndInvoiceStream.cs | 2 +- NostrStreamer/Services/StreamEventBuilder.cs | 20 +- .../StreamManager/NostrStreamManager.cs | 36 +- .../StreamManager/StreamManagerContext.cs | 1 + .../StreamManager/StreamManagerFactory.cs | 49 +- NostrStreamer/Services/UserService.cs | 4 +- NostrStreamer/appsettings.json | 5 +- docker/srs-edge.conf | 2 +- docker/srs-origin.conf | 8 +- 27 files changed, 1723 insertions(+), 87 deletions(-) create mode 100644 NostrStreamer/ApiModel/CreateStreamKeyRequest.cs create mode 100644 NostrStreamer/Database/Configuration/UserStreamKeyConfiguration.cs create mode 100644 NostrStreamer/Database/UserStreamKey.cs create mode 100644 NostrStreamer/Migrations/20240821112332_StreamKeys.Designer.cs create mode 100644 NostrStreamer/Migrations/20240821112332_StreamKeys.cs create mode 100644 NostrStreamer/Migrations/20240821132917_MigrateStreamInfo.Designer.cs create mode 100644 NostrStreamer/Migrations/20240821132917_MigrateStreamInfo.cs diff --git a/NostrStreamer/ApiModel/Account.cs b/NostrStreamer/ApiModel/Account.cs index 2a5f57d..a4ee9e2 100644 --- a/NostrStreamer/ApiModel/Account.cs +++ b/NostrStreamer/ApiModel/Account.cs @@ -4,9 +4,6 @@ namespace NostrStreamer.ApiModel; public class Account { - [JsonProperty("event")] - public PatchEvent? Event { get; init; } - [JsonProperty("endpoints")] public List Endpoints { get; init; } = new(); diff --git a/NostrStreamer/ApiModel/CreateStreamKeyRequest.cs b/NostrStreamer/ApiModel/CreateStreamKeyRequest.cs new file mode 100644 index 0000000..22d7231 --- /dev/null +++ b/NostrStreamer/ApiModel/CreateStreamKeyRequest.cs @@ -0,0 +1,8 @@ +namespace NostrStreamer.ApiModel; + +public class CreateStreamKeyRequest +{ + public PatchEvent Event { get; init; } = null!; + + public DateTime? Expires { get; init; } +} \ No newline at end of file diff --git a/NostrStreamer/ApiModel/PatchEvent.cs b/NostrStreamer/ApiModel/PatchEvent.cs index 0bad53b..81f7136 100644 --- a/NostrStreamer/ApiModel/PatchEvent.cs +++ b/NostrStreamer/ApiModel/PatchEvent.cs @@ -4,6 +4,9 @@ namespace NostrStreamer.ApiModel; public class PatchEvent { + [JsonProperty("id")] + public Guid Id { get; init; } + [JsonProperty("title")] public string Title { get; init; } = null!; diff --git a/NostrStreamer/Controllers/NostrController.cs b/NostrStreamer/Controllers/NostrController.cs index 869ad5c..9104fe3 100644 --- a/NostrStreamer/Controllers/NostrController.cs +++ b/NostrStreamer/Controllers/NostrController.cs @@ -27,10 +27,12 @@ public class NostrController : Controller private readonly IClipService _clipService; private readonly ILogger _logger; private readonly PushSender _pushSender; + private readonly StreamEventBuilder _eventBuilder; public NostrController(StreamerContext db, Config config, StreamManagerFactory streamManager, UserService userService, - IClipService clipService, ILogger logger, PushSender pushSender) + IClipService clipService, ILogger logger, PushSender pushSender, + StreamEventBuilder eventBuilder) { _db = db; _config = config; @@ -39,6 +41,7 @@ public class NostrController : Controller _clipService = clipService; _logger = logger; _pushSender = pushSender; + _eventBuilder = eventBuilder; } [HttpGet("account")] @@ -57,15 +60,6 @@ public class NostrController : Controller var account = new Account { - Event = new PatchEvent() - { - Title = user.Title ?? "", - Summary = user.Summary ?? "", - Image = user.Image ?? "", - ContentWarning = user.ContentWarning, - Tags = user.SplitTags(), - Goal = user.Goal - }, Endpoints = endpoints.Select(a => new AccountEndpoint { Name = a.Name, @@ -100,9 +94,9 @@ public class NostrController : Controller var pubkey = GetPubKey(); if (string.IsNullOrEmpty(pubkey)) return Unauthorized(); - await _userService.UpdateStreamInfo(pubkey, req); try { + await _userService.UpdateStreamInfo(pubkey, req); var streamManager = await _streamManagerFactory.ForCurrentStream(pubkey); await streamManager.UpdateEvent(); } @@ -404,6 +398,91 @@ public class NostrController : Controller } } + + [HttpGet("keys")] + public async Task ListStreamKeys([FromQuery] int page = 0, [FromQuery] int pageSize = 100) + { + var userPubkey = GetPubKey(); + if (string.IsNullOrEmpty(userPubkey)) + return BadRequest(); + + try + { + var keys = await _db.StreamKeys + .AsNoTracking() + .Include(a => a.UserStream) + .Where(a => a.UserPubkey == userPubkey) + .Skip(page * pageSize) + .Take(pageSize) + .Select(a => + new + { + a.Id, + a.Created, + a.Key, + a.Expires, + Stream = a.UserStream.Event + }) + .ToListAsync(); + + return Json(new + { + items = keys, + page, pageSize + }); + } + catch (Exception e) + { + return Json(new + { + error = e.Message + }); + } + } + + [HttpPost("keys")] + public async Task CreateStreamKey([FromBody] CreateStreamKeyRequest req) + { + var userPubkey = GetPubKey(); + if (string.IsNullOrEmpty(userPubkey)) + return BadRequest(); + + try + { + var newStream = new UserStream() + { + PubKey = userPubkey, + State = UserStreamState.Planned, + }; + newStream.PatchStream(req.Event); + var ev = _eventBuilder.CreateStreamEvent(newStream); + newStream.Event = NostrJson.Serialize(ev) ?? ""; + + var newKey = new UserStreamKey() + { + Expires = req.Expires, + Key = Guid.NewGuid().ToString(), + StreamId = newStream.Id, + UserPubkey = userPubkey + }; + _db.Streams.Add(newStream); + _db.StreamKeys.Add(newKey); + await _db.SaveChangesAsync(); + return Json(new + { + newKey.Key, + newStream.Event + }); + } + catch (Exception e) + { + return Json(new + { + error = e.Message + }); + } + } + private async Task GetUser() { var pk = GetPubKey(); diff --git a/NostrStreamer/Controllers/PlaylistController.cs b/NostrStreamer/Controllers/PlaylistController.cs index 2613120..3ee33ac 100644 --- a/NostrStreamer/Controllers/PlaylistController.cs +++ b/NostrStreamer/Controllers/PlaylistController.cs @@ -41,7 +41,7 @@ public class PlaylistController : Controller var streamManager = await _streamManagerFactory.ForStream(id); var userStream = streamManager.GetStream(); - var path = $"/{userStream.Endpoint.App}/{variant}/{userStream.User.StreamKey}.m3u8"; + var path = $"/{userStream.Endpoint.App}/{variant}/{userStream.Key}.m3u8"; var ub = new UriBuilder(_config.SrsHttpHost) { Path = path, @@ -130,7 +130,7 @@ public class PlaylistController : Controller 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}"); + a.Name == userStream.Key && a.App == $"{userStream.Endpoint.App}/{variant.SourceName}"); var resArg = stream?.Video != default ? $"RESOLUTION={stream.Video?.Width}x{stream.Video?.Height}" : variant.ToResolutionArg(); @@ -171,7 +171,7 @@ public class PlaylistController : Controller var streamManager = await _streamManagerFactory.ForStream(id); var userStream = streamManager.GetStream(); - var path = $"/{userStream.Endpoint.App}/{variant}/{userStream.User.StreamKey}-{segment}"; + var path = $"/{userStream.Endpoint.App}/{variant}/{userStream.Key}-{segment}"; await ProxyRequest(path); } catch @@ -253,7 +253,7 @@ public class PlaylistController : Controller private async Task GetHlsCtx(UserStream stream) { - var path = $"/{stream.Endpoint.App}/source/{stream.User.StreamKey}.m3u8"; + var path = $"/{stream.Endpoint.App}/source/{stream.Key}.m3u8"; var ub = new Uri(_config.SrsHttpHost, path); var req = CreateProxyRequest(ub); using var rsp = await _client.SendAsync(req); diff --git a/NostrStreamer/Controllers/PodcastController.cs b/NostrStreamer/Controllers/PodcastController.cs index 8e1d05c..c387430 100644 --- a/NostrStreamer/Controllers/PodcastController.cs +++ b/NostrStreamer/Controllers/PodcastController.cs @@ -29,8 +29,8 @@ public class PodcastController(StreamerContext db, Config config) : Controller pod.LiveItem = new() { Guid = stream.Id, - Title = stream.User.Title ?? "", - Description = stream.User.Summary, + Title = stream.Title ?? "", + Description = stream.Summary, Status = stream.State.ToString().ToLower(), Start = stream.Starts, End = stream.Ends ?? new DateTime(), diff --git a/NostrStreamer/Database/Configuration/UserStreamConfiguration.cs b/NostrStreamer/Database/Configuration/UserStreamConfiguration.cs index d85da20..e9c7d19 100644 --- a/NostrStreamer/Database/Configuration/UserStreamConfiguration.cs +++ b/NostrStreamer/Database/Configuration/UserStreamConfiguration.cs @@ -8,8 +8,6 @@ public class UserStreamConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.HasKey(a => a.Id); - builder.Property(a => a.StreamId) - .IsRequired(); builder.Property(a => a.Starts) .IsRequired(); @@ -35,6 +33,13 @@ public class UserStreamConfiguration : IEntityTypeConfiguration builder.Property(a => a.AdmissionCost); + builder.Property(a => a.Title); + builder.Property(a => a.Image); + builder.Property(a => a.Summary); + builder.Property(a => a.ContentWarning); + builder.Property(a => a.Tags); + builder.Property(a => a.Goal); + builder.HasOne(a => a.Endpoint) .WithMany() .HasForeignKey(a => a.EndpointId); diff --git a/NostrStreamer/Database/Configuration/UserStreamKeyConfiguration.cs b/NostrStreamer/Database/Configuration/UserStreamKeyConfiguration.cs new file mode 100644 index 0000000..21f944c --- /dev/null +++ b/NostrStreamer/Database/Configuration/UserStreamKeyConfiguration.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace NostrStreamer.Database.Configuration; + +public class UserStreamKeyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(a => a.Id); + + builder.Property(a => a.Key) + .IsRequired(); + + builder.Property(a => a.Created) + .IsRequired(); + + builder.Property(a => a.Expires) + .IsRequired(false); + + builder.HasOne(a => a.UserStream) + .WithOne(a => a.StreamKey) + .HasPrincipalKey(a => a.Id) + .HasForeignKey(a => a.StreamId); + + builder.HasOne(a => a.User) + .WithMany(a => a.StreamKeys) + .HasForeignKey(a => a.UserPubkey) + .HasPrincipalKey(a => a.PubKey); + } +} \ No newline at end of file diff --git a/NostrStreamer/Database/StreamerContext.cs b/NostrStreamer/Database/StreamerContext.cs index 597d65d..ca14962 100644 --- a/NostrStreamer/Database/StreamerContext.cs +++ b/NostrStreamer/Database/StreamerContext.cs @@ -37,4 +37,6 @@ public class StreamerContext : DbContext public DbSet PushSubscriptions => Set(); public DbSet PushSubscriptionTargets => Set(); + + public DbSet StreamKeys => Set(); } diff --git a/NostrStreamer/Database/User.cs b/NostrStreamer/Database/User.cs index c368579..d20b12b 100644 --- a/NostrStreamer/Database/User.cs +++ b/NostrStreamer/Database/User.cs @@ -67,4 +67,5 @@ public class User public List Payments { get; init; } = new(); public List Streams { get; init; } = new(); public List Forwards { get; init; } = new(); -} + public List StreamKeys { get; init; } = new(); +} \ No newline at end of file diff --git a/NostrStreamer/Database/UserStream.cs b/NostrStreamer/Database/UserStream.cs index 1f215f7..046548f 100644 --- a/NostrStreamer/Database/UserStream.cs +++ b/NostrStreamer/Database/UserStream.cs @@ -7,14 +7,42 @@ public class UserStream public string PubKey { get; init; } = null!; public User User { get; init; } = null!; - public string StreamId { get; init; } = null!; - public DateTime Starts { get; init; } = DateTime.UtcNow; public DateTime? Ends { get; set; } public UserStreamState State { get; set; } + /// + /// Stream title + /// + public string? Title { get; set; } + + /// + /// Stream summary + /// + public string? Summary { get; set; } + + /// + /// Stream cover image + /// + public string? Image { get; set; } + + /// + /// Comma seperated tags + /// + public string? Tags { get; set; } + + /// + /// Any content warning tag (NIP-36) + /// + public string? ContentWarning { get; set; } + + /// + /// Stream goal + /// + public string? Goal { get; set; } + /// /// Nostr Event for this stream /// @@ -25,7 +53,7 @@ public class UserStream /// public string? Thumbnail { get; set; } - public Guid EndpointId { get; init; } + public Guid EndpointId { get; set; } public IngestEndpoint Endpoint { get; init; } = null!; /// @@ -58,10 +86,15 @@ public class UserStream public List Guests { get; init; } = new(); public List Recordings { get; init; } = new(); + + public UserStreamKey? StreamKey { get; init; } + + public string Key => StreamKey?.Key ?? User.StreamKey; } public enum UserStreamState { + Unknown = 0, Planned = 1, Live = 2, Ended = 3 diff --git a/NostrStreamer/Database/UserStreamKey.cs b/NostrStreamer/Database/UserStreamKey.cs new file mode 100644 index 0000000..7efdc1b --- /dev/null +++ b/NostrStreamer/Database/UserStreamKey.cs @@ -0,0 +1,25 @@ +namespace NostrStreamer.Database; + +/// +/// Single use stream keys +/// +public class UserStreamKey +{ + public Guid Id { get; init; } = Guid.NewGuid(); + + public string UserPubkey { get; init; } = null!; + public User User { get; init; } = null!; + + public string Key { get; init; } = null!; + + public DateTime Created { get; init; } = DateTime.UtcNow; + + /// + /// Expiry of the key when it can no longer be used + /// + public DateTime? Expires { get; init; } + + + public Guid StreamId { get; init; } + public UserStream UserStream { get; init; } = null!; +} \ No newline at end of file diff --git a/NostrStreamer/Extensions.cs b/NostrStreamer/Extensions.cs index 3ded084..ad2254f 100644 --- a/NostrStreamer/Extensions.cs +++ b/NostrStreamer/Extensions.cs @@ -3,6 +3,7 @@ using Amazon.Runtime; using Amazon.S3; using Igdb; using MaxMind.GeoIP2; +using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; using Nostr.Client.Identifiers; using Nostr.Client.Json; @@ -49,10 +50,11 @@ public static class Extensions }); } - public static string[] SplitTags(this User user) + public static string[] SplitTags(this UserStream stream) { - return !string.IsNullOrEmpty(user.Tags) ? - user.Tags.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) : Array.Empty(); + return !string.IsNullOrEmpty(stream.Tags) + ? stream.Tags.Split(",", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + : Array.Empty(); } public static (double lat, double lon)? GetLocation(this HttpContext ctx, IGeoIP2DatabaseReader db) @@ -90,14 +92,16 @@ public static class Extensions var num1 = longitude * (Math.PI / 180.0); var d2 = otherLatitude * (Math.PI / 180.0); var num2 = otherLongitude * (Math.PI / 180.0) - num1; - var d3 = Math.Pow(Math.Sin((d2 - d1) / 2.0), 2.0) + Math.Cos(d1) * Math.Cos(d2) * Math.Pow(Math.Sin(num2 / 2.0), 2.0); + var d3 = Math.Pow(Math.Sin((d2 - d1) / 2.0), 2.0) + + Math.Cos(d1) * Math.Cos(d2) * Math.Pow(Math.Sin(num2 / 2.0), 2.0); return 6376500.0 * (2.0 * Math.Atan2(Math.Sqrt(d3), Math.Sqrt(1.0 - d3))); } public static string GetHost(this NostrEvent ev) { - var hostTag = ev.Tags!.FirstOrDefault(a => a.TagIdentifier == "p" && a.AdditionalData[2] == "host")?.AdditionalData[0]; + var hostTag = ev.Tags!.FirstOrDefault(a => a.TagIdentifier == "p" && a.AdditionalData[2] == "host") + ?.AdditionalData[0]; if (!string.IsNullOrEmpty(hostTag)) { return hostTag; @@ -105,7 +109,7 @@ public static class Extensions return ev.Pubkey!; } - + public static NostrIdentifier ToIdentifier(this NostrEvent ev) { if ((long)ev.Kind is >= 30_000 and < 40_000) @@ -132,6 +136,33 @@ public static class Extensions Genres = a.Genres.Select(b => b.Name).ToList() }; } + + public static async Task CopyLastStreamDetails(this UserStream stream, StreamerContext db) + { + var lastStream = await db.Streams + .AsNoTracking() + .Where(a => a.PubKey == stream.PubKey) + .OrderByDescending(a => a.Starts) + .FirstOrDefaultAsync(); + + + stream.Title = lastStream?.Title; + stream.Summary = lastStream?.Summary; + stream.Image = lastStream?.Image; + stream.ContentWarning = lastStream?.ContentWarning; + stream.Tags = lastStream?.Tags; + stream.Goal = lastStream?.Goal; + } + + public static void PatchStream(this UserStream stream, PatchEvent ev) + { + stream.Title = ev.Title; + stream.Summary = ev.Summary; + stream.Image = ev.Image; + stream.ContentWarning = ev.ContentWarning; + stream.Tags = ev.Tags.Length > 0 ? string.Join(',', ev.Tags) : null; + stream.Goal = ev.Goal; + } } public class Variant @@ -161,7 +192,8 @@ public class Variant } var strSplit = str.Split(":"); - if (strSplit.Length != 3 || !int.TryParse(strSplit[1][..^1], out var h) || !int.TryParse(strSplit[2], out var b)) + if (strSplit.Length != 3 || !int.TryParse(strSplit[1][..^1], out var h) || + !int.TryParse(strSplit[2], out var b)) { throw new FormatException(); } @@ -203,4 +235,4 @@ public class Variant return $"BANDWIDTH={Bandwidth * 1000}"; } -} +} \ No newline at end of file diff --git a/NostrStreamer/Migrations/20240821112332_StreamKeys.Designer.cs b/NostrStreamer/Migrations/20240821112332_StreamKeys.Designer.cs new file mode 100644 index 0000000..ffa4336 --- /dev/null +++ b/NostrStreamer/Migrations/20240821112332_StreamKeys.Designer.cs @@ -0,0 +1,558 @@ +// +using System; +using System.Collections.Generic; +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("20240821112332_StreamKeys")] + partial class StreamKeys + { + /// + 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.IngestEndpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("App") + .IsRequired() + .HasColumnType("text"); + + b.Property>("Capabilities") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Cost") + .HasColumnType("integer"); + + b.Property("Forward") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("App") + .IsUnique(); + + b.ToTable("Endpoints"); + }); + + modelBuilder.Entity("NostrStreamer.Database.Payment", b => + { + b.Property("PaymentHash") + .HasColumnType("text"); + + b.Property("Amount") + .HasColumnType("numeric(20,0)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Fee") + .HasColumnType("numeric(20,0)"); + + b.Property("Invoice") + .HasColumnType("text"); + + b.Property("IsPaid") + .HasColumnType("boolean"); + + b.Property("Nostr") + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("PaymentHash"); + + b.HasIndex("PubKey"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("NostrStreamer.Database.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone"); + + b.Property("Pubkey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("NostrStreamer.Database.PushSubscriptionTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SubscriberPubkey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetPubkey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("TargetPubkey"); + + b.HasIndex("SubscriberPubkey", "TargetPubkey") + .IsUnique(); + + b.ToTable("PushSubscriptionTargets"); + }); + + modelBuilder.Entity("NostrStreamer.Database.StreamTickets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .HasColumnType("uuid"); + + b.Property("UserStreamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserStreamId"); + + b.ToTable("StreamTickets"); + }); + + modelBuilder.Entity("NostrStreamer.Database.User", b => + { + b.Property("PubKey") + .HasColumnType("text"); + + b.Property("Balance") + .HasColumnType("bigint"); + + b.Property("ContentWarning") + .HasColumnType("text"); + + b.Property("Goal") + .HasColumnType("text"); + + b.Property("Image") + .HasColumnType("text"); + + b.Property("StreamKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("TosAccepted") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("PubKey"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdmissionCost") + .HasColumnType("numeric"); + + b.Property("EdgeIp") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndpointId") + .HasColumnType("uuid"); + + b.Property("Ends") + .HasColumnType("timestamp with time zone"); + + b.Property("Event") + .IsRequired() + .HasColumnType("text"); + + b.Property("ForwardClientId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastSegment") + .HasColumnType("timestamp with time zone"); + + b.Property("Length") + .HasColumnType("numeric"); + + b.Property("MilliSatsCollected") + .HasColumnType("numeric"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Starts") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("StreamId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Thumbnail") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EndpointId"); + + b.HasIndex("PubKey"); + + b.ToTable("Streams"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamClip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("TakenByPubkey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserStreamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserStreamId"); + + b.ToTable("Clips"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamForwards", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Target") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserPubkey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserPubkey"); + + b.ToTable("Forwards"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamGuest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Relay") + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("text"); + + b.Property("Sig") + .HasColumnType("text"); + + b.Property("StreamId") + .HasColumnType("uuid"); + + b.Property("ZapSplit") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("StreamId", "PubKey") + .IsUnique(); + + b.ToTable("Guests"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .HasColumnType("text"); + + b.Property("StreamId") + .HasColumnType("uuid"); + + b.Property("UserPubkey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StreamId") + .IsUnique(); + + b.HasIndex("UserPubkey"); + + b.ToTable("StreamKeys"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamRecording", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Duration") + .HasColumnType("double precision"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserStreamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserStreamId"); + + b.ToTable("Recordings"); + }); + + modelBuilder.Entity("NostrStreamer.Database.Payment", b => + { + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("Payments") + .HasForeignKey("PubKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NostrStreamer.Database.StreamTickets", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "UserStream") + .WithMany() + .HasForeignKey("UserStreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserStream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStream", b => + { + b.HasOne("NostrStreamer.Database.IngestEndpoint", "Endpoint") + .WithMany() + .HasForeignKey("EndpointId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("Streams") + .HasForeignKey("PubKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Endpoint"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamClip", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "UserStream") + .WithMany() + .HasForeignKey("UserStreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserStream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamForwards", b => + { + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("Forwards") + .HasForeignKey("UserPubkey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamGuest", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "Stream") + .WithMany("Guests") + .HasForeignKey("StreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Stream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamKey", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "UserStream") + .WithOne() + .HasForeignKey("NostrStreamer.Database.UserStreamKey", "StreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("StreamKeys") + .HasForeignKey("UserPubkey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("UserStream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamRecording", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "Stream") + .WithMany("Recordings") + .HasForeignKey("UserStreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Stream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.User", b => + { + b.Navigation("Forwards"); + + b.Navigation("Payments"); + + b.Navigation("StreamKeys"); + + b.Navigation("Streams"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStream", b => + { + b.Navigation("Guests"); + + b.Navigation("Recordings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/NostrStreamer/Migrations/20240821112332_StreamKeys.cs b/NostrStreamer/Migrations/20240821112332_StreamKeys.cs new file mode 100644 index 0000000..00dbfdb --- /dev/null +++ b/NostrStreamer/Migrations/20240821112332_StreamKeys.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NostrStreamer.Migrations +{ + /// + public partial class StreamKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "StreamKeys", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserPubkey = table.Column(type: "text", nullable: false), + Key = table.Column(type: "text", nullable: false), + Created = table.Column(type: "timestamp with time zone", nullable: false), + Expires = table.Column(type: "timestamp with time zone", nullable: true), + StreamId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StreamKeys", x => x.Id); + table.ForeignKey( + name: "FK_StreamKeys_Streams_StreamId", + column: x => x.StreamId, + principalTable: "Streams", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StreamKeys_Users_UserPubkey", + column: x => x.UserPubkey, + principalTable: "Users", + principalColumn: "PubKey", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_StreamKeys_StreamId", + table: "StreamKeys", + column: "StreamId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_StreamKeys_UserPubkey", + table: "StreamKeys", + column: "UserPubkey"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "StreamKeys"); + } + } +} diff --git a/NostrStreamer/Migrations/20240821132917_MigrateStreamInfo.Designer.cs b/NostrStreamer/Migrations/20240821132917_MigrateStreamInfo.Designer.cs new file mode 100644 index 0000000..f98de26 --- /dev/null +++ b/NostrStreamer/Migrations/20240821132917_MigrateStreamInfo.Designer.cs @@ -0,0 +1,557 @@ +// +using System; +using System.Collections.Generic; +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("20240821132917_MigrateStreamInfo")] + partial class MigrateStreamInfo + { + /// + 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.IngestEndpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("App") + .IsRequired() + .HasColumnType("text"); + + b.Property>("Capabilities") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Cost") + .HasColumnType("integer"); + + b.Property("Forward") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("App") + .IsUnique(); + + b.ToTable("Endpoints"); + }); + + modelBuilder.Entity("NostrStreamer.Database.Payment", b => + { + b.Property("PaymentHash") + .HasColumnType("text"); + + b.Property("Amount") + .HasColumnType("numeric(20,0)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Fee") + .HasColumnType("numeric(20,0)"); + + b.Property("Invoice") + .HasColumnType("text"); + + b.Property("IsPaid") + .HasColumnType("boolean"); + + b.Property("Nostr") + .HasColumnType("text"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("PaymentHash"); + + b.HasIndex("PubKey"); + + b.ToTable("Payments"); + }); + + modelBuilder.Entity("NostrStreamer.Database.PushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Auth") + .IsRequired() + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone"); + + b.Property("Pubkey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("PushSubscriptions"); + }); + + modelBuilder.Entity("NostrStreamer.Database.PushSubscriptionTarget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("SubscriberPubkey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetPubkey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("TargetPubkey"); + + b.HasIndex("SubscriberPubkey", "TargetPubkey") + .IsUnique(); + + b.ToTable("PushSubscriptionTargets"); + }); + + modelBuilder.Entity("NostrStreamer.Database.StreamTickets", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .HasColumnType("uuid"); + + b.Property("UserStreamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserStreamId"); + + b.ToTable("StreamTickets"); + }); + + modelBuilder.Entity("NostrStreamer.Database.User", b => + { + b.Property("PubKey") + .HasColumnType("text"); + + b.Property("Balance") + .HasColumnType("bigint"); + + b.Property("StreamKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("TosAccepted") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("PubKey"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdmissionCost") + .HasColumnType("numeric"); + + b.Property("ContentWarning") + .HasColumnType("text"); + + b.Property("EdgeIp") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndpointId") + .HasColumnType("uuid"); + + b.Property("Ends") + .HasColumnType("timestamp with time zone"); + + b.Property("Event") + .IsRequired() + .HasColumnType("text"); + + b.Property("ForwardClientId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Goal") + .HasColumnType("text"); + + b.Property("Image") + .HasColumnType("text"); + + b.Property("LastSegment") + .HasColumnType("timestamp with time zone"); + + b.Property("Length") + .HasColumnType("numeric"); + + b.Property("MilliSatsCollected") + .HasColumnType("numeric"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Starts") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("Thumbnail") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("EndpointId"); + + b.HasIndex("PubKey"); + + b.ToTable("Streams"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamClip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("TakenByPubkey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserStreamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserStreamId"); + + b.ToTable("Clips"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamForwards", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Target") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserPubkey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserPubkey"); + + b.ToTable("Forwards"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamGuest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PubKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Relay") + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("text"); + + b.Property("Sig") + .HasColumnType("text"); + + b.Property("StreamId") + .HasColumnType("uuid"); + + b.Property("ZapSplit") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("StreamId", "PubKey") + .IsUnique(); + + b.ToTable("Guests"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("StreamId") + .HasColumnType("uuid"); + + b.Property("UserPubkey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StreamId") + .IsUnique(); + + b.HasIndex("UserPubkey"); + + b.ToTable("StreamKeys"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamRecording", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Duration") + .HasColumnType("double precision"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserStreamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserStreamId"); + + b.ToTable("Recordings"); + }); + + modelBuilder.Entity("NostrStreamer.Database.Payment", b => + { + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("Payments") + .HasForeignKey("PubKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NostrStreamer.Database.StreamTickets", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "UserStream") + .WithMany() + .HasForeignKey("UserStreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserStream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStream", b => + { + b.HasOne("NostrStreamer.Database.IngestEndpoint", "Endpoint") + .WithMany() + .HasForeignKey("EndpointId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("Streams") + .HasForeignKey("PubKey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Endpoint"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamClip", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "UserStream") + .WithMany() + .HasForeignKey("UserStreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserStream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamForwards", b => + { + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("Forwards") + .HasForeignKey("UserPubkey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamGuest", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "Stream") + .WithMany("Guests") + .HasForeignKey("StreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Stream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamKey", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "UserStream") + .WithOne("StreamKey") + .HasForeignKey("NostrStreamer.Database.UserStreamKey", "StreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("StreamKeys") + .HasForeignKey("UserPubkey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("UserStream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStreamRecording", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "Stream") + .WithMany("Recordings") + .HasForeignKey("UserStreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Stream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.User", b => + { + b.Navigation("Forwards"); + + b.Navigation("Payments"); + + b.Navigation("StreamKeys"); + + b.Navigation("Streams"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStream", b => + { + b.Navigation("Guests"); + + b.Navigation("Recordings"); + + b.Navigation("StreamKey"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/NostrStreamer/Migrations/20240821132917_MigrateStreamInfo.cs b/NostrStreamer/Migrations/20240821132917_MigrateStreamInfo.cs new file mode 100644 index 0000000..edbd897 --- /dev/null +++ b/NostrStreamer/Migrations/20240821132917_MigrateStreamInfo.cs @@ -0,0 +1,149 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NostrStreamer.Migrations +{ + /// + public partial class MigrateStreamInfo : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ContentWarning", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Goal", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Image", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Summary", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Tags", + table: "Users"); + + migrationBuilder.DropColumn( + name: "Title", + table: "Users"); + + migrationBuilder.DropColumn( + name: "StreamId", + table: "Streams"); + + migrationBuilder.AddColumn( + name: "ContentWarning", + table: "Streams", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Goal", + table: "Streams", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Image", + table: "Streams", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Summary", + table: "Streams", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Tags", + table: "Streams", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Title", + table: "Streams", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ContentWarning", + table: "Streams"); + + migrationBuilder.DropColumn( + name: "Goal", + table: "Streams"); + + migrationBuilder.DropColumn( + name: "Image", + table: "Streams"); + + migrationBuilder.DropColumn( + name: "Summary", + table: "Streams"); + + migrationBuilder.DropColumn( + name: "Tags", + table: "Streams"); + + migrationBuilder.DropColumn( + name: "Title", + table: "Streams"); + + migrationBuilder.AddColumn( + name: "ContentWarning", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Goal", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Image", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Summary", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Tags", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Title", + table: "Users", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "StreamId", + table: "Streams", + type: "text", + nullable: false, + defaultValue: ""); + } + } +} diff --git a/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs b/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs index b2940da..8098ac6 100644 --- a/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs +++ b/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs @@ -238,6 +238,9 @@ namespace NostrStreamer.Migrations b.Property("AdmissionCost") .HasColumnType("numeric"); + b.Property("ContentWarning") + .HasColumnType("text"); + b.Property("EdgeIp") .IsRequired() .HasColumnType("text"); @@ -256,6 +259,12 @@ namespace NostrStreamer.Migrations .IsRequired() .HasColumnType("text"); + b.Property("Goal") + .HasColumnType("text"); + + b.Property("Image") + .HasColumnType("text"); + b.Property("LastSegment") .HasColumnType("timestamp with time zone"); @@ -275,13 +284,18 @@ namespace NostrStreamer.Migrations b.Property("State") .HasColumnType("integer"); - b.Property("StreamId") - .IsRequired() + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Tags") .HasColumnType("text"); b.Property("Thumbnail") .HasColumnType("text"); + b.Property("Title") + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("EndpointId"); @@ -376,6 +390,39 @@ namespace NostrStreamer.Migrations b.ToTable("Guests"); }); + modelBuilder.Entity("NostrStreamer.Database.UserStreamKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("StreamId") + .HasColumnType("uuid"); + + b.Property("UserPubkey") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("StreamId") + .IsUnique(); + + b.HasIndex("UserPubkey"); + + b.ToTable("StreamKeys"); + }); + modelBuilder.Entity("NostrStreamer.Database.UserStreamRecording", b => { b.Property("Id") @@ -476,6 +523,25 @@ namespace NostrStreamer.Migrations b.Navigation("Stream"); }); + modelBuilder.Entity("NostrStreamer.Database.UserStreamKey", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "UserStream") + .WithOne("StreamKey") + .HasForeignKey("NostrStreamer.Database.UserStreamKey", "StreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("NostrStreamer.Database.User", "User") + .WithMany("StreamKeys") + .HasForeignKey("UserPubkey") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("UserStream"); + }); + modelBuilder.Entity("NostrStreamer.Database.UserStreamRecording", b => { b.HasOne("NostrStreamer.Database.UserStream", "Stream") @@ -493,6 +559,8 @@ namespace NostrStreamer.Migrations b.Navigation("Payments"); + b.Navigation("StreamKeys"); + b.Navigation("Streams"); }); @@ -501,6 +569,8 @@ namespace NostrStreamer.Migrations b.Navigation("Guests"); b.Navigation("Recordings"); + + b.Navigation("StreamKey"); }); #pragma warning restore 612, 618 } diff --git a/NostrStreamer/Services/Background/LndInvoiceStream.cs b/NostrStreamer/Services/Background/LndInvoiceStream.cs index ab1213e..bb87c3c 100644 --- a/NostrStreamer/Services/Background/LndInvoiceStream.cs +++ b/NostrStreamer/Services/Background/LndInvoiceStream.cs @@ -72,7 +72,7 @@ public class LndInvoicesStream : BackgroundService } catch (Exception ex) { - _logger.LogError(ex, "Subscribe invoices failed"); + //_logger.LogError(ex, "Subscribe invoices failed"); } await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); diff --git a/NostrStreamer/Services/StreamEventBuilder.cs b/NostrStreamer/Services/StreamEventBuilder.cs index 494f35e..64216d2 100644 --- a/NostrStreamer/Services/StreamEventBuilder.cs +++ b/NostrStreamer/Services/StreamEventBuilder.cs @@ -22,7 +22,7 @@ public class StreamEventBuilder _nostrClient = nostrClient; } - public NostrEvent CreateStreamEvent(User user, UserStream stream) + public NostrEvent CreateStreamEvent(UserStream stream) { var status = stream.State switch { @@ -35,11 +35,11 @@ public class StreamEventBuilder var tags = new List { new("d", stream.Id.ToString()), - new("title", user.Title ?? ""), - new("summary", user.Summary ?? ""), - new("image", stream.Thumbnail ?? user.Image ?? ""), + new("title", stream.Title ?? ""), + new("summary", stream.Summary ?? ""), + new("image", stream.Thumbnail ?? stream.Image ?? ""), new("status", status), - new("p", user.PubKey, "", "host"), + new("p", stream.PubKey, "wss://relay.zap.stream", "host"), new("relays", _config.Relays), new("starts", new DateTimeOffset(stream.Starts).ToUnixTimeSeconds().ToString()), new("service", new Uri(_config.ApiHost, "/api/nostr").ToString()) @@ -51,9 +51,9 @@ public class StreamEventBuilder tags.Add(new("streaming", new Uri(_config.DataHost, $"stream/{stream.Id}.m3u8").ToString())); tags.Add(new("current_participants", viewers.ToString())); - if (!string.IsNullOrEmpty(user.ContentWarning)) + if (!string.IsNullOrEmpty(stream.ContentWarning)) { - tags.Add(new("content-warning", user.ContentWarning)); + tags.Add(new("content-warning", stream.ContentWarning)); } } else if (status == "ended") @@ -70,14 +70,14 @@ public class StreamEventBuilder } } - foreach (var tag in user.SplitTags()) + foreach (var tag in stream.SplitTags()) { tags.Add(new("t", tag)); } - if (!string.IsNullOrEmpty(user.Goal)) + if (!string.IsNullOrEmpty(stream.Goal)) { - tags.Add(new("goal", user.Goal)); + tags.Add(new("goal", stream.Goal)); } var ev = new NostrEvent diff --git a/NostrStreamer/Services/StreamManager/NostrStreamManager.cs b/NostrStreamer/Services/StreamManager/NostrStreamManager.cs index a503517..8350f40 100644 --- a/NostrStreamer/Services/StreamManager/NostrStreamManager.cs +++ b/NostrStreamer/Services/StreamManager/NostrStreamManager.cs @@ -57,7 +57,7 @@ public class NostrStreamManager : IStreamManager TestCanStream(); var fwds = new List { - $"rtmp://127.0.0.1:1935/{_context.UserStream.Endpoint.App}/{_context.User.StreamKey}?vhost={_context.UserStream.Endpoint.Forward}" + $"rtmp://127.0.0.1:1935/{_context.UserStream.Endpoint.App}/{_context.StreamKey}?vhost={_context.UserStream.Endpoint.Forward}" }; var dataProtector = _dataProtectionProvider.CreateProtector("forward-targets"); @@ -103,7 +103,6 @@ public class NostrStreamManager : IStreamManager } } }); - } public async Task StreamStopped() @@ -194,20 +193,27 @@ public class NostrStreamManager : IStreamManager { //var matches = new Regex("\\.(\\d+)\\.[\\w]{2,4}$").Match(segment.AbsolutePath); - if (_context.UserStream.Endpoint.Capabilities.Contains("dvr:source")) + try { - var result = await _dvrStore.UploadRecording(_context.UserStream, segment); - _context.Db.Recordings.Add(new() + if (_context.UserStream.Endpoint.Capabilities.Contains("dvr:source")) { - Id = result.Id, - UserStreamId = _context.UserStream.Id, - Url = result.Result.ToString(), - Duration = result.Duration, - Timestamp = DateTime - .UtcNow //DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(matches.Groups[1].Value)).UtcDateTime - }); + var result = await _dvrStore.UploadRecording(_context.UserStream, segment); + _context.Db.Recordings.Add(new() + { + Id = result.Id, + UserStreamId = _context.UserStream.Id, + Url = result.Result.ToString(), + Duration = result.Duration, + Timestamp = DateTime + .UtcNow //DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(matches.Groups[1].Value)).UtcDateTime + }); - await _context.Db.SaveChangesAsync(); + await _context.Db.SaveChangesAsync(); + } + } + catch (Exception ex) + { + _logger.LogWarning("Failed to save recording segment {}, {}", segment, ex.Message); } await _context.Db.Streams @@ -242,7 +248,7 @@ public class NostrStreamManager : IStreamManager var existingEvent = _context.UserStream.GetEvent(); var oldViewers = existingEvent?.Tags?.FindFirstTagValue("current_participants"); - var newEvent = _eventBuilder.CreateStreamEvent(_context.User, _context.UserStream); + var newEvent = _eventBuilder.CreateStreamEvent(_context.UserStream); var newViewers = newEvent?.Tags?.FindFirstTagValue("current_participants"); if (newEvent != default && int.TryParse(oldViewers, out var a) && int.TryParse(newViewers, out var b) && a != b) @@ -260,7 +266,7 @@ public class NostrStreamManager : IStreamManager DateTime? ends = state == UserStreamState.Ended ? DateTime.UtcNow : null; _context.UserStream.State = state; _context.UserStream.Ends = ends; - var ev = _eventBuilder.CreateStreamEvent(_context.User, _context.UserStream); + var ev = _eventBuilder.CreateStreamEvent(_context.UserStream); await _context.Db.Streams.Where(a => a.Id == _context.UserStream.Id) .ExecuteUpdateAsync(o => o.SetProperty(v => v.State, state) diff --git a/NostrStreamer/Services/StreamManager/StreamManagerContext.cs b/NostrStreamer/Services/StreamManager/StreamManagerContext.cs index 02b1f5e..bdfb793 100644 --- a/NostrStreamer/Services/StreamManager/StreamManagerContext.cs +++ b/NostrStreamer/Services/StreamManager/StreamManagerContext.cs @@ -9,4 +9,5 @@ public class StreamManagerContext public User User => UserStream.User; public StreamInfo? StreamInfo { get; init; } public SrsApi EdgeApi { get; init; } = null!; + public string StreamKey { get; init; } = null!; } diff --git a/NostrStreamer/Services/StreamManager/StreamManagerFactory.cs b/NostrStreamer/Services/StreamManager/StreamManagerFactory.cs index 56974a8..4b04a7c 100644 --- a/NostrStreamer/Services/StreamManager/StreamManagerFactory.cs +++ b/NostrStreamer/Services/StreamManager/StreamManagerFactory.cs @@ -28,7 +28,9 @@ public class StreamManagerFactory var user = await _db.Users .AsNoTracking() .Include(a => a.Forwards) - .SingleOrDefaultAsync(a => a.StreamKey.Equals(info.StreamKey)); + .Include(user => user.StreamKeys) + .SingleOrDefaultAsync(a => + a.StreamKey.Equals(info.StreamKey) || a.StreamKeys.Any(b => b.Key == info.StreamKey)); if (user == default) throw new Exception("No user found"); @@ -53,24 +55,28 @@ public class StreamManagerFactory throw new Exception("User account blocked"); } - var existingLive = await _db.Streams - .SingleOrDefaultAsync(a => a.State == UserStreamState.Live && a.PubKey == user.PubKey); + var singleUseKey = user.StreamKeys.FirstOrDefault(a => a.Key == info.StreamKey); + + var existingLive = singleUseKey != default + ? await _db.Streams.SingleOrDefaultAsync(a => a.Id == singleUseKey.StreamId) + : await _db.Streams + .SingleOrDefaultAsync(a => a.State == UserStreamState.Live && a.PubKey == user.PubKey); var stream = existingLive ?? new UserStream { EndpointId = ep.Id, PubKey = user.PubKey, - StreamId = "", State = UserStreamState.Live, EdgeIp = info.EdgeIp, - ForwardClientId = info.ClientId + ForwardClientId = info.ClientId, }; // add new stream if (existingLive == default) { - var ev = _eventBuilder.CreateStreamEvent(user, stream); - stream.Event = JsonConvert.SerializeObject(ev, NostrSerializer.Settings); + await stream.CopyLastStreamDetails(_db); + var ev = _eventBuilder.CreateStreamEvent(stream); + stream.Event = NostrJson.Serialize(ev) ?? ""; _db.Streams.Add(stream); await _db.SaveChangesAsync(); } @@ -85,18 +91,19 @@ public class StreamManagerFactory var ctx = new StreamManagerContext { Db = _db, + StreamKey = info.StreamKey, UserStream = new() { Id = stream.Id, PubKey = stream.PubKey, - StreamId = stream.StreamId, State = stream.State, EdgeIp = stream.EdgeIp, ForwardClientId = stream.ForwardClientId, Endpoint = ep, User = user }, - EdgeApi = new SrsApi(_serviceProvider.GetRequiredService(), new Uri($"http://{stream.EdgeIp}:1985")) + EdgeApi = new SrsApi(_serviceProvider.GetRequiredService(), + new Uri($"http://{stream.EdgeIp}:1985")) }; return new NostrStreamManager(_loggerFactory.CreateLogger(), ctx, _serviceProvider); @@ -108,6 +115,7 @@ public class StreamManagerFactory .AsNoTracking() .Include(a => a.User) .Include(a => a.Endpoint) + .Include(a => a.StreamKey) .FirstOrDefaultAsync(a => a.Id == id); if (stream == default) throw new Exception("No live stream"); @@ -115,8 +123,10 @@ public class StreamManagerFactory var ctx = new StreamManagerContext { Db = _db, + StreamKey = stream.StreamKey?.Key ?? stream.User.StreamKey, UserStream = stream, - EdgeApi = new SrsApi(_serviceProvider.GetRequiredService(), new Uri($"http://{stream.EdgeIp}:1985")) + EdgeApi = new SrsApi(_serviceProvider.GetRequiredService(), + new Uri($"http://{stream.EdgeIp}:1985")) }; return new NostrStreamManager(_loggerFactory.CreateLogger(), ctx, _serviceProvider); @@ -128,6 +138,7 @@ public class StreamManagerFactory .AsNoTracking() .Include(a => a.User) .Include(a => a.Endpoint) + .Include(a => a.StreamKey) .FirstOrDefaultAsync(a => a.PubKey.Equals(pubkey) && a.State == UserStreamState.Live); if (stream == default) throw new Exception("No live stream"); @@ -135,8 +146,10 @@ public class StreamManagerFactory var ctx = new StreamManagerContext { Db = _db, + StreamKey = stream.StreamKey?.Key ?? stream.User.StreamKey, UserStream = stream, - EdgeApi = new SrsApi(_serviceProvider.GetRequiredService(), new Uri($"http://{stream.EdgeIp}:1985")) + EdgeApi = new SrsApi(_serviceProvider.GetRequiredService(), + new Uri($"http://{stream.EdgeIp}:1985")) }; return new NostrStreamManager(_loggerFactory.CreateLogger(), ctx, _serviceProvider); @@ -148,11 +161,13 @@ public class StreamManagerFactory .AsNoTracking() .Include(a => a.User) .Include(a => a.Endpoint) + .Include(a => a.StreamKey) .OrderByDescending(a => a.Starts) .FirstOrDefaultAsync(a => - a.User.StreamKey.Equals(info.StreamKey) && - a.Endpoint.App.Equals(info.App) && - a.State == UserStreamState.Live); + (a.StreamKey != default && a.StreamKey.Key == info.StreamKey) || + (a.User.StreamKey.Equals(info.StreamKey) && + a.Endpoint.App.Equals(info.App) && + a.State == UserStreamState.Live)); if (stream == default) { @@ -162,11 +177,13 @@ public class StreamManagerFactory var ctx = new StreamManagerContext { Db = _db, + StreamKey = info.StreamKey, UserStream = stream, StreamInfo = info, - EdgeApi = new SrsApi(_serviceProvider.GetRequiredService(), new Uri($"http://{stream.EdgeIp}:1985")) + EdgeApi = new SrsApi(_serviceProvider.GetRequiredService(), + new Uri($"http://{stream.EdgeIp}:1985")) }; return new NostrStreamManager(_loggerFactory.CreateLogger(), ctx, _serviceProvider); } -} +} \ No newline at end of file diff --git a/NostrStreamer/Services/UserService.cs b/NostrStreamer/Services/UserService.cs index 4b8accf..f6d746b 100644 --- a/NostrStreamer/Services/UserService.cs +++ b/NostrStreamer/Services/UserService.cs @@ -254,8 +254,8 @@ public class UserService public async Task UpdateStreamInfo(string pubkey, PatchEvent req) { - await _db.Users - .Where(a => a.PubKey == pubkey) + await _db.Streams + .Where(a => a.Id == req.Id && a.PubKey == pubkey) .ExecuteUpdateAsync(o => o.SetProperty(v => v.Title, req.Title) .SetProperty(v => v.Summary, req.Summary) .SetProperty(v => v.Image, req.Image) diff --git a/NostrStreamer/appsettings.json b/NostrStreamer/appsettings.json index 48b7402..7c893fe 100644 --- a/NostrStreamer/appsettings.json +++ b/NostrStreamer/appsettings.json @@ -4,7 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning", - "System.Net.Http.HttpClient": "Error" + "System.Net.Http.HttpClient": "Error", + "NostrStreamer.NostrAuthHandler": "Error", } }, "AllowedHosts": "*", @@ -35,7 +36,7 @@ "PublicHost": "http://localhost:9000" }, "SnortApi": "https://api.snort.social", - "GeoIpDatabase": "C:\\Users\\Kieran\\Downloads\\GeoLite2-City.mmdb", + "GeoIpDatabase": "/home/kieran/Downloads/GeoLite2-City.mmdb", "Edges": [ { "Name": "US0", diff --git a/docker/srs-edge.conf b/docker/srs-edge.conf index 0369c4d..19cee71 100644 --- a/docker/srs-edge.conf +++ b/docker/srs-edge.conf @@ -118,6 +118,6 @@ vhost full.in.zap.stream { vhost __defaultVhost__ { forward { enabled on; - backend http://host.docker.internal:5295/api/srs; + backend http://172.17.0.1:5295/api/srs; } } \ No newline at end of file diff --git a/docker/srs-origin.conf b/docker/srs-origin.conf index 93ee91b..ea27e15 100644 --- a/docker/srs-origin.conf +++ b/docker/srs-origin.conf @@ -32,10 +32,10 @@ vhost hls.zap.stream { http_hooks { enabled on; - on_publish http://host.docker.internal:5295/api/srs; - on_unpublish http://host.docker.internal:5295/api/srs; - on_hls http://host.docker.internal:5295/api/srs; - on_dvr http://host.docker.internal:5295/api/srs; + on_publish http://172.17.0.1:5295/api/srs; + on_unpublish http://172.17.0.1:5295/api/srs; + on_hls http://172.17.0.1:5295/api/srs; + on_dvr http://172.17.0.1:5295/api/srs; } dvr {