diff --git a/NostrStreamer/Database/UserStream.cs b/NostrStreamer/Database/UserStream.cs index 6317efd..f166030 100644 --- a/NostrStreamer/Database/UserStream.cs +++ b/NostrStreamer/Database/UserStream.cs @@ -39,6 +39,12 @@ public class UserStream public string ForwardClientId { get; set; } = null!; public DateTime LastSegment { get; set; } = DateTime.UtcNow; + + /// + /// Total sats charged during this stream + /// + public decimal MilliSatsCollected { get; set; } + public List Guests { get; init; } = new(); } diff --git a/NostrStreamer/Migrations/20240221202747_TrackSatsConsumed.Designer.cs b/NostrStreamer/Migrations/20240221202747_TrackSatsConsumed.Designer.cs new file mode 100644 index 0000000..e142ea6 --- /dev/null +++ b/NostrStreamer/Migrations/20240221202747_TrackSatsConsumed.Designer.cs @@ -0,0 +1,461 @@ +// +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("20240221202747_TrackSatsConsumed")] + partial class TrackSatsConsumed + { + /// + 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("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.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("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("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.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.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.UserStreamRecording", b => + { + b.HasOne("NostrStreamer.Database.UserStream", "Stream") + .WithMany() + .HasForeignKey("UserStreamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Stream"); + }); + + modelBuilder.Entity("NostrStreamer.Database.User", b => + { + b.Navigation("Forwards"); + + b.Navigation("Payments"); + + b.Navigation("Streams"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStream", b => + { + b.Navigation("Guests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/NostrStreamer/Migrations/20240221202747_TrackSatsConsumed.cs b/NostrStreamer/Migrations/20240221202747_TrackSatsConsumed.cs new file mode 100644 index 0000000..0b66052 --- /dev/null +++ b/NostrStreamer/Migrations/20240221202747_TrackSatsConsumed.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NostrStreamer.Migrations +{ + /// + public partial class TrackSatsConsumed : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MilliSatsCollected", + table: "Streams", + type: "numeric", + nullable: false, + defaultValue: 0m); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MilliSatsCollected", + table: "Streams"); + } + } +} diff --git a/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs b/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs index d07280d..5c9b836 100644 --- a/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs +++ b/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs @@ -225,6 +225,9 @@ namespace NostrStreamer.Migrations b.Property("LastSegment") .HasColumnType("timestamp with time zone"); + b.Property("MilliSatsCollected") + .HasColumnType("numeric"); + b.Property("PubKey") .IsRequired() .HasColumnType("text"); diff --git a/NostrStreamer/Services/StreamManager/NostrStreamManager.cs b/NostrStreamer/Services/StreamManager/NostrStreamManager.cs index d4e7ea3..b9d06cd 100644 --- a/NostrStreamer/Services/StreamManager/NostrStreamManager.cs +++ b/NostrStreamer/Services/StreamManager/NostrStreamManager.cs @@ -21,7 +21,8 @@ public class NostrStreamManager : IStreamManager private readonly NostrServicesClient _nostrApi; private readonly IDataProtectionProvider _dataProtectionProvider; - public NostrStreamManager(ILogger logger, StreamManagerContext context, IServiceProvider serviceProvider) + public NostrStreamManager(ILogger logger, StreamManagerContext context, + IServiceProvider serviceProvider) { _logger = logger; _context = context; @@ -87,10 +88,12 @@ public class NostrStreamManager : IStreamManager { try { - var profile = await _nostrApi.Profile(_context.User.PubKey); - var name = profile?.Name ?? NostrConverter.ToBech32(_context.User.PubKey, "npub"); + var npub = NostrConverter.ToBech32(_context.User.PubKey, "npub")!; + var profile = await _nostrApi.Profile(npub); + var name = profile?.Name ?? npub; var id = ev.ToIdentifier(); - await _webhook.SendMessage(_config.DiscordLiveWebhook, $"{name} went live!\nhttps://zap.stream/{id.ToBech32()}"); + await _webhook.SendMessage(_config.DiscordLiveWebhook, + $"{name} went live!\nhttps://zap.stream/{id.ToBech32()}"); } catch (Exception ex) { @@ -112,12 +115,16 @@ public class NostrStreamManager : IStreamManager var cost = (long)Math.Ceiling(_context.UserStream.Endpoint.Cost * (duration / 60d)); if (cost > 0) { - await _context.Db.Users - .Where(a => a.PubKey == _context.User.PubKey) - .ExecuteUpdateAsync(o => o.SetProperty(v => v.Balance, v => v.Balance - cost)); + await _context.Db.Streams + .Include(a => a.User) + .Where(a => a.PubKey == _context.User.PubKey && a.Id == _context.UserStream.Id) + .ExecuteUpdateAsync(o => + o.SetProperty(v => v.User.Balance, v => v.User.Balance - cost) + .SetProperty(v => v.MilliSatsCollected, v => v.MilliSatsCollected + cost)); } - _logger.LogInformation("Stream consumed {n} seconds for {pubkey} costing {cost:#,##0} milli-sats", duration, _context.User.PubKey, + _logger.LogInformation("Stream produced {n} seconds for {pubkey} costing {cost:#,##0} milli-sats", duration, + _context.User.PubKey, cost); if (_context.User.Balance >= balanceAlertThreshold && _context.User.Balance - cost < balanceAlertThreshold) @@ -168,7 +175,8 @@ public class NostrStreamManager : IStreamManager UserStreamId = _context.UserStream.Id, Url = result.Result.ToString(), Duration = result.Duration, - Timestamp = DateTime.UtcNow //DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(matches.Groups[1].Value)).UtcDateTime + Timestamp = DateTime + .UtcNow //DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(matches.Groups[1].Value)).UtcDateTime }); await _context.Db.SaveChangesAsync(); @@ -212,7 +220,8 @@ public class NostrStreamManager : IStreamManager if (newEvent != default && int.TryParse(oldViewers, out var a) && int.TryParse(newViewers, out var b) && a != b) { await _context.Db.Streams.Where(a => a.Id == _context.UserStream.Id) - .ExecuteUpdateAsync(o => o.SetProperty(v => v.Event, JsonConvert.SerializeObject(newEvent, NostrSerializer.Settings))); + .ExecuteUpdateAsync(o => + o.SetProperty(v => v.Event, JsonConvert.SerializeObject(newEvent, NostrSerializer.Settings))); _eventBuilder.BroadcastEvent(newEvent); } @@ -233,4 +242,4 @@ public class NostrStreamManager : IStreamManager _eventBuilder.BroadcastEvent(ev); return ev; } -} +} \ No newline at end of file