From 865747fbae65bfd809f4c4a8ed7d967cd122040a Mon Sep 17 00:00:00 2001 From: Kieran Date: Fri, 8 Dec 2023 14:50:02 +0000 Subject: [PATCH] Clips --- NostrStreamer/Controllers/NostrController.cs | 21 +- .../UserStreamClipConfiguration.cs | 25 ++ NostrStreamer/Database/StreamerContext.cs | 2 + NostrStreamer/Database/UserStreamClip.cs | 15 + .../20231208131841_Clips.Designer.cs | 394 ++++++++++++++++++ .../Migrations/20231208131841_Clips.cs | 48 +++ .../StreamerContextModelSnapshot.cs | 38 ++ NostrStreamer/NostrStreamer.csproj | 4 +- NostrStreamer/Program.cs | 10 +- NostrStreamer/Services/Clips/ClipGenerator.cs | 34 ++ NostrStreamer/Services/Clips/IClipService.cs | 8 + NostrStreamer/Services/Clips/S3ClipService.cs | 80 ++++ NostrStreamer/Services/StreamEventBuilder.cs | 3 +- 13 files changed, 676 insertions(+), 6 deletions(-) create mode 100644 NostrStreamer/Database/Configuration/UserStreamClipConfiguration.cs create mode 100644 NostrStreamer/Database/UserStreamClip.cs create mode 100644 NostrStreamer/Migrations/20231208131841_Clips.Designer.cs create mode 100644 NostrStreamer/Migrations/20231208131841_Clips.cs create mode 100644 NostrStreamer/Services/Clips/ClipGenerator.cs create mode 100644 NostrStreamer/Services/Clips/IClipService.cs create mode 100644 NostrStreamer/Services/Clips/S3ClipService.cs diff --git a/NostrStreamer/Controllers/NostrController.cs b/NostrStreamer/Controllers/NostrController.cs index dfaf8e3..0ea47d3 100644 --- a/NostrStreamer/Controllers/NostrController.cs +++ b/NostrStreamer/Controllers/NostrController.cs @@ -8,11 +8,12 @@ using Nostr.Client.Json; using NostrStreamer.ApiModel; using NostrStreamer.Database; using NostrStreamer.Services; +using NostrStreamer.Services.Clips; using NostrStreamer.Services.StreamManager; namespace NostrStreamer.Controllers; -[Authorize] +[Authorize(AuthenticationSchemes = NostrAuth.Scheme)] [EnableCors] [Route("/api/nostr")] public class NostrController : Controller @@ -21,13 +22,16 @@ public class NostrController : Controller private readonly Config _config; private readonly StreamManagerFactory _streamManagerFactory; private readonly UserService _userService; + private readonly IClipService _clipService; - public NostrController(StreamerContext db, Config config, StreamManagerFactory streamManager, UserService userService) + public NostrController(StreamerContext db, Config config, StreamManagerFactory streamManager, UserService userService, + IClipService clipService) { _db = db; _config = config; _streamManagerFactory = streamManager; _userService = userService; + _clipService = clipService; } [HttpGet("account")] @@ -161,6 +165,19 @@ public class NostrController : Controller return Ok(); } + [HttpPost("clip/{id:guid}")] + public async Task CreateClip([FromRoute] Guid id) + { + var pk = GetPubKey(); + var clip = await _clipService.CreateClip(id, pk); + if (clip == default) return StatusCode(500); + + return Json(new + { + url = clip.Url + }); + } + private async Task GetUser() { var pk = GetPubKey(); diff --git a/NostrStreamer/Database/Configuration/UserStreamClipConfiguration.cs b/NostrStreamer/Database/Configuration/UserStreamClipConfiguration.cs new file mode 100644 index 0000000..010bf94 --- /dev/null +++ b/NostrStreamer/Database/Configuration/UserStreamClipConfiguration.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace NostrStreamer.Database.Configuration; + +public class UserStreamClipConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(a => a.Id); + builder.Property(a => a.Created) + .IsRequired(); + + builder.Property(a => a.TakenByPubkey) + .IsRequired(); + + builder.Property(a => a.Url) + .IsRequired(); + + builder.HasOne(a => a.UserStream) + .WithMany() + .HasForeignKey(a => a.UserStreamId) + .HasPrincipalKey(a => a.Id); + } +} diff --git a/NostrStreamer/Database/StreamerContext.cs b/NostrStreamer/Database/StreamerContext.cs index b577266..ff68619 100644 --- a/NostrStreamer/Database/StreamerContext.cs +++ b/NostrStreamer/Database/StreamerContext.cs @@ -31,4 +31,6 @@ public class StreamerContext : DbContext public DbSet Recordings => Set(); public DbSet Forwards => Set(); + + public DbSet Clips => Set(); } diff --git a/NostrStreamer/Database/UserStreamClip.cs b/NostrStreamer/Database/UserStreamClip.cs new file mode 100644 index 0000000..f810d0a --- /dev/null +++ b/NostrStreamer/Database/UserStreamClip.cs @@ -0,0 +1,15 @@ +namespace NostrStreamer.Database; + +public class UserStreamClip +{ + public Guid Id { get; init; } = Guid.NewGuid(); + + public Guid UserStreamId { get; init; } + public UserStream UserStream { get; init; } = null!; + + public DateTime Created { get; init; } = DateTime.UtcNow; + + public string TakenByPubkey { get; init; } = null!; + + public string Url { get; init; } = null!; +} diff --git a/NostrStreamer/Migrations/20231208131841_Clips.Designer.cs b/NostrStreamer/Migrations/20231208131841_Clips.Designer.cs new file mode 100644 index 0000000..691f8f6 --- /dev/null +++ b/NostrStreamer/Migrations/20231208131841_Clips.Designer.cs @@ -0,0 +1,394 @@ +// +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("20231208131841_Clips")] + partial class Clips + { + /// + 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.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("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/20231208131841_Clips.cs b/NostrStreamer/Migrations/20231208131841_Clips.cs new file mode 100644 index 0000000..147ff2d --- /dev/null +++ b/NostrStreamer/Migrations/20231208131841_Clips.cs @@ -0,0 +1,48 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NostrStreamer.Migrations +{ + /// + public partial class Clips : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Clips", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + UserStreamId = table.Column(type: "uuid", nullable: false), + Created = table.Column(type: "timestamp with time zone", nullable: false), + TakenByPubkey = table.Column(type: "text", nullable: false), + Url = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Clips", x => x.Id); + table.ForeignKey( + name: "FK_Clips_Streams_UserStreamId", + column: x => x.UserStreamId, + principalTable: "Streams", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Clips_UserStreamId", + table: "Clips", + column: "UserStreamId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Clips"); + } + } +} diff --git a/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs b/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs index fa4d194..54e3685 100644 --- a/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs +++ b/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs @@ -187,6 +187,33 @@ namespace NostrStreamer.Migrations 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") @@ -301,6 +328,17 @@ namespace NostrStreamer.Migrations 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") diff --git a/NostrStreamer/NostrStreamer.csproj b/NostrStreamer/NostrStreamer.csproj index 9dec796..e0bbd52 100644 --- a/NostrStreamer/NostrStreamer.csproj +++ b/NostrStreamer/NostrStreamer.csproj @@ -38,7 +38,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + @@ -48,7 +48,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/NostrStreamer/Program.cs b/NostrStreamer/Program.cs index ed80e87..80a3365 100644 --- a/NostrStreamer/Program.cs +++ b/NostrStreamer/Program.cs @@ -8,6 +8,7 @@ using Nostr.Client.Client; using NostrStreamer.Database; using NostrStreamer.Services; using NostrStreamer.Services.Background; +using NostrStreamer.Services.Clips; using NostrStreamer.Services.Dvr; using NostrStreamer.Services.StreamManager; using NostrStreamer.Services.Thumbnail; @@ -71,9 +72,12 @@ internal static class Program services.AddTransient(); services.AddTransient(); + // dvr services + services.AddTransient(); + + // thumbnail services services.AddTransient(); services.AddHostedService(); - services.AddTransient(); // lnd services services.AddSingleton(); @@ -82,6 +86,10 @@ internal static class Program // game services services.AddSingleton(); + // clip services + services.AddTransient(); + services.AddTransient(); + var app = builder.Build(); using (var scope = app.Services.CreateScope()) diff --git a/NostrStreamer/Services/Clips/ClipGenerator.cs b/NostrStreamer/Services/Clips/ClipGenerator.cs new file mode 100644 index 0000000..7f7bb99 --- /dev/null +++ b/NostrStreamer/Services/Clips/ClipGenerator.cs @@ -0,0 +1,34 @@ +using FFMpegCore; +using NostrStreamer.Database; + +namespace NostrStreamer.Services.Clips; + +public class ClipGenerator +{ + private readonly ILogger _logger; + private readonly Config _config; + + public ClipGenerator(ILogger logger, Config config) + { + _logger = logger; + _config = config; + } + + public async Task GenerateClip(UserStream stream) + { + const int clipLength = 20; + var path = Path.ChangeExtension(Path.GetTempFileName(), ".mp4"); + var cmd = FFMpegArguments + .FromUrlInput(new Uri(_config.DataHost, $"stream/{stream.Id}.m3u8"), + inOpt => + { + inOpt.WithCustomArgument($"-ss -{clipLength}"); + }) + .OutputToFile(path, true, o => { o.WithDuration(TimeSpan.FromSeconds(clipLength)); }) + .CancellableThrough(new CancellationTokenSource(TimeSpan.FromSeconds(60)).Token); + + _logger.LogInformation("Running command {cmd}", cmd.Arguments); + await cmd.ProcessAsynchronously(); + return path; + } +} diff --git a/NostrStreamer/Services/Clips/IClipService.cs b/NostrStreamer/Services/Clips/IClipService.cs new file mode 100644 index 0000000..5ca8885 --- /dev/null +++ b/NostrStreamer/Services/Clips/IClipService.cs @@ -0,0 +1,8 @@ +namespace NostrStreamer.Services.Clips; + +public interface IClipService +{ + Task CreateClip(Guid streamId, string takenBy); +} + +public record ClipResult(Uri Url); diff --git a/NostrStreamer/Services/Clips/S3ClipService.cs b/NostrStreamer/Services/Clips/S3ClipService.cs new file mode 100644 index 0000000..0491d31 --- /dev/null +++ b/NostrStreamer/Services/Clips/S3ClipService.cs @@ -0,0 +1,80 @@ +using Amazon.S3; +using Microsoft.EntityFrameworkCore; +using NostrStreamer.Database; + +namespace NostrStreamer.Services.Clips; + +public class S3ClipService : IClipService +{ + private readonly ClipGenerator _generator; + private readonly AmazonS3Client _client; + private readonly Config _config; + private readonly StreamerContext _context; + + public S3ClipService(ClipGenerator generator, Config config, StreamerContext context) + { + _generator = generator; + _client = config.S3Store.CreateClient(); + ; + _config = config; + _context = context; + } + + public async Task CreateClip(Guid streamId, string takenBy) + { + var stream = await _context.Streams + .Include(a => a.User) + .Include(a => a.Endpoint) + .FirstOrDefaultAsync(a => a.Id == streamId); + + if (stream == default) + { + return default; + } + + var tmpClip = await _generator.GenerateClip(stream); + + var clipId = Guid.NewGuid(); + var s3Path = $"{stream.Id}/clips/{clipId}.mp4"; + + await using var fs = new FileStream(tmpClip, FileMode.Open, FileAccess.Read); + await _client.PutObjectAsync(new() + { + BucketName = _config.S3Store.BucketName, + Key = s3Path, + InputStream = fs, + AutoCloseStream = false, + AutoResetStreamPosition = false, + ContentType = "video/mp4", + DisablePayloadSigning = _config.S3Store.DisablePayloadSigning + }); + + var uri = _client.GetPreSignedURL(new() + { + BucketName = _config.S3Store.BucketName, + Key = s3Path, + Expires = DateTime.UtcNow.AddYears(1000) + }); + + var ub = new UriBuilder(uri) + { + Scheme = _config.S3Store.PublicHost.Scheme, + Host = _config.S3Store.PublicHost.Host, + Port = _config.S3Store.PublicHost.Port + }; + + + var clipObj = new UserStreamClip() + { + Id = clipId, + UserStreamId = stream.Id, + TakenByPubkey = takenBy, + Url = ub.Uri.ToString() + }; + + _context.Clips.Add(clipObj); + await _context.SaveChangesAsync(); + + return new(ub.Uri); + } +} diff --git a/NostrStreamer/Services/StreamEventBuilder.cs b/NostrStreamer/Services/StreamEventBuilder.cs index 28fcfc7..aecc5f9 100644 --- a/NostrStreamer/Services/StreamEventBuilder.cs +++ b/NostrStreamer/Services/StreamEventBuilder.cs @@ -41,7 +41,8 @@ public class StreamEventBuilder new("status", status), new("p", user.PubKey, "", "host"), new("relays", _config.Relays), - new("starts", new DateTimeOffset(stream.Starts).ToUnixTimeSeconds().ToString()) + new("starts", new DateTimeOffset(stream.Starts).ToUnixTimeSeconds().ToString()), + new("service", new Uri(_config.ApiHost, "/api/nostr").ToString()) }; if (status == "live")