From d2b56c14f90c609dac87b574d442eef0348307c3 Mon Sep 17 00:00:00 2001 From: Kieran Date: Tue, 1 Aug 2023 23:08:57 +0100 Subject: [PATCH] Upload thumbnails to S3 --- NostrStreamer/Config.cs | 2 +- .../Controllers/PlaylistController.cs | 33 +- .../Configuration/UserStreamConfiguration.cs | 2 +- NostrStreamer/Database/UserStream.cs | 4 +- .../20230801210018_Thumbnails.Designer.cs | 314 ++++++++++++++++++ .../Migrations/20230801210018_Thumbnails.cs | 28 ++ .../StreamerContextModelSnapshot.cs | 6 +- NostrStreamer/Program.cs | 5 +- .../BackgroundStreamManager.cs | 2 +- .../{ => Background}/LndInvoiceStream.cs | 2 +- .../{ => Background}/NostrListener.cs | 2 +- .../{ => Background}/ThumbnailGenerator.cs | 5 +- NostrStreamer/Services/Dvr/S3DvrStore.cs | 4 +- NostrStreamer/Services/StreamEventBuilder.cs | 2 +- .../StreamManager/NostrStreamManager.cs | 7 +- .../Thumbnail/BaseThumbnailService.cs | 29 ++ .../Services/Thumbnail/IThumbnailService.cs | 8 + .../Services/Thumbnail/S3ThumbnailService.cs | 80 +++++ NostrStreamer/Services/ThumbnailService.cs | 55 --- NostrStreamer/appsettings.json | 2 +- 20 files changed, 482 insertions(+), 110 deletions(-) create mode 100644 NostrStreamer/Migrations/20230801210018_Thumbnails.Designer.cs create mode 100644 NostrStreamer/Migrations/20230801210018_Thumbnails.cs rename NostrStreamer/Services/{ => Background}/BackgroundStreamManager.cs (97%) rename NostrStreamer/Services/{ => Background}/LndInvoiceStream.cs (98%) rename NostrStreamer/Services/{ => Background}/NostrListener.cs (98%) rename NostrStreamer/Services/{ => Background}/ThumbnailGenerator.cs (93%) create mode 100644 NostrStreamer/Services/Thumbnail/BaseThumbnailService.cs create mode 100644 NostrStreamer/Services/Thumbnail/IThumbnailService.cs create mode 100644 NostrStreamer/Services/Thumbnail/S3ThumbnailService.cs delete mode 100644 NostrStreamer/Services/ThumbnailService.cs diff --git a/NostrStreamer/Config.cs b/NostrStreamer/Config.cs index 4e2a95a..d651813 100644 --- a/NostrStreamer/Config.cs +++ b/NostrStreamer/Config.cs @@ -37,7 +37,7 @@ public class Config public LndConfig Lnd { get; init; } = null!; - public S3BlobConfig DvrStore { get; init; } = null!; + public S3BlobConfig S3Store { get; init; } = null!; public DateTime TosDate { get; init; } } diff --git a/NostrStreamer/Controllers/PlaylistController.cs b/NostrStreamer/Controllers/PlaylistController.cs index 9224ec1..d7515d8 100644 --- a/NostrStreamer/Controllers/PlaylistController.cs +++ b/NostrStreamer/Controllers/PlaylistController.cs @@ -16,11 +16,9 @@ public class PlaylistController : Controller private readonly SrsApi _srsApi; private readonly ViewCounter _viewCounter; private readonly StreamManagerFactory _streamManagerFactory; - private readonly ThumbnailService _thumbnailService; public PlaylistController(Config config, ILogger logger, - HttpClient client, SrsApi srsApi, ViewCounter viewCounter, StreamManagerFactory streamManagerFactory, - ThumbnailService thumbnailService) + HttpClient client, SrsApi srsApi, ViewCounter viewCounter, StreamManagerFactory streamManagerFactory) { _config = config; _logger = logger; @@ -28,7 +26,6 @@ public class PlaylistController : Controller _srsApi = srsApi; _viewCounter = viewCounter; _streamManagerFactory = streamManagerFactory; - _thumbnailService = thumbnailService; } [ResponseCache(Duration = 1, Location = ResponseCacheLocation.Any)] @@ -85,34 +82,6 @@ public class PlaylistController : Controller } } - [ResponseCache(Duration = 30, Location = ResponseCacheLocation.Any)] - [HttpGet("{id}.jpg")] - public async Task GetPreview([FromRoute] Guid id) - { - try - { - var stream = _thumbnailService.GetThumbnail(id); - if (stream != default) - { - Response.ContentLength = stream.Length; - Response.ContentType = "image/jpg"; - Response.Headers.CacheControl = "public, max-age=60"; - await Response.StartAsync(); - await stream.CopyToAsync(Response.Body); - await Response.CompleteAsync(); - } - else - { - Response.StatusCode = 404; - } - } - catch (Exception ex) - { - _logger.LogWarning("Failed to get preview image for {id} {message}", id, ex.Message); - Response.StatusCode = 404; - } - } - [ResponseCache(Duration = 1, Location = ResponseCacheLocation.Any)] [HttpGet("{pubkey}.m3u8")] public async Task GetCurrentStreamRedirect([FromRoute] string pubkey) diff --git a/NostrStreamer/Database/Configuration/UserStreamConfiguration.cs b/NostrStreamer/Database/Configuration/UserStreamConfiguration.cs index 1a7a601..748d877 100644 --- a/NostrStreamer/Database/Configuration/UserStreamConfiguration.cs +++ b/NostrStreamer/Database/Configuration/UserStreamConfiguration.cs @@ -22,7 +22,7 @@ public class UserStreamConfiguration : IEntityTypeConfiguration builder.Property(a => a.Event) .IsRequired(); - builder.Property(a => a.Recording); + builder.Property(a => a.Thumbnail); builder.Property(a => a.EdgeIp) .IsRequired(); diff --git a/NostrStreamer/Database/UserStream.cs b/NostrStreamer/Database/UserStream.cs index 4731158..f6ef129 100644 --- a/NostrStreamer/Database/UserStream.cs +++ b/NostrStreamer/Database/UserStream.cs @@ -21,9 +21,9 @@ public class UserStream public string Event { get; set; } = null!; /// - /// Recording URL of ended stream + /// URL of auto-generated thumbnail /// - public string? Recording { get; set; } + public string? Thumbnail { get; set; } public Guid EndpointId { get; init; } public IngestEndpoint Endpoint { get; init; } = null!; diff --git a/NostrStreamer/Migrations/20230801210018_Thumbnails.Designer.cs b/NostrStreamer/Migrations/20230801210018_Thumbnails.Designer.cs new file mode 100644 index 0000000..318aa27 --- /dev/null +++ b/NostrStreamer/Migrations/20230801210018_Thumbnails.Designer.cs @@ -0,0 +1,314 @@ +// +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("20230801210018_Thumbnails")] + partial class Thumbnails + { + /// + 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") + .IsRequired() + .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("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") + .IsRequired() + .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("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.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.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("Payments"); + + b.Navigation("Streams"); + }); + + modelBuilder.Entity("NostrStreamer.Database.UserStream", b => + { + b.Navigation("Guests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/NostrStreamer/Migrations/20230801210018_Thumbnails.cs b/NostrStreamer/Migrations/20230801210018_Thumbnails.cs new file mode 100644 index 0000000..7b7f79e --- /dev/null +++ b/NostrStreamer/Migrations/20230801210018_Thumbnails.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NostrStreamer.Migrations +{ + /// + public partial class Thumbnails : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Recording", + table: "Streams", + newName: "Thumbnail"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "Thumbnail", + table: "Streams", + newName: "Recording"); + } + } +} diff --git a/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs b/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs index 0497052..60cd995 100644 --- a/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs +++ b/NostrStreamer/Migrations/StreamerContextModelSnapshot.cs @@ -161,9 +161,6 @@ namespace NostrStreamer.Migrations .IsRequired() .HasColumnType("text"); - b.Property("Recording") - .HasColumnType("text"); - b.Property("Starts") .HasColumnType("timestamp with time zone"); @@ -174,6 +171,9 @@ namespace NostrStreamer.Migrations .IsRequired() .HasColumnType("text"); + b.Property("Thumbnail") + .HasColumnType("text"); + b.HasKey("Id"); b.HasIndex("EndpointId"); diff --git a/NostrStreamer/Program.cs b/NostrStreamer/Program.cs index 86daa4a..f43ac33 100644 --- a/NostrStreamer/Program.cs +++ b/NostrStreamer/Program.cs @@ -5,8 +5,10 @@ using Microsoft.EntityFrameworkCore; using Nostr.Client.Client; using NostrStreamer.Database; using NostrStreamer.Services; +using NostrStreamer.Services.Background; using NostrStreamer.Services.Dvr; using NostrStreamer.Services.StreamManager; +using NostrStreamer.Services.Thumbnail; namespace NostrStreamer; @@ -58,7 +60,8 @@ internal static class Program services.AddTransient(); services.AddTransient(); services.AddTransient(); - services.AddTransient(); + + services.AddTransient(); services.AddHostedService(); services.AddTransient(); diff --git a/NostrStreamer/Services/BackgroundStreamManager.cs b/NostrStreamer/Services/Background/BackgroundStreamManager.cs similarity index 97% rename from NostrStreamer/Services/BackgroundStreamManager.cs rename to NostrStreamer/Services/Background/BackgroundStreamManager.cs index 773f305..eb32795 100644 --- a/NostrStreamer/Services/BackgroundStreamManager.cs +++ b/NostrStreamer/Services/Background/BackgroundStreamManager.cs @@ -2,7 +2,7 @@ using Microsoft.EntityFrameworkCore; using NostrStreamer.Database; using NostrStreamer.Services.StreamManager; -namespace NostrStreamer.Services; +namespace NostrStreamer.Services.Background; public class BackgroundStreamManager : BackgroundService { diff --git a/NostrStreamer/Services/LndInvoiceStream.cs b/NostrStreamer/Services/Background/LndInvoiceStream.cs similarity index 98% rename from NostrStreamer/Services/LndInvoiceStream.cs rename to NostrStreamer/Services/Background/LndInvoiceStream.cs index e4d4e04..e6886fb 100644 --- a/NostrStreamer/Services/LndInvoiceStream.cs +++ b/NostrStreamer/Services/Background/LndInvoiceStream.cs @@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore; using Nostr.Client.Utils; using NostrStreamer.Database; -namespace NostrStreamer.Services; +namespace NostrStreamer.Services.Background; public class LndInvoicesStream : BackgroundService { diff --git a/NostrStreamer/Services/NostrListener.cs b/NostrStreamer/Services/Background/NostrListener.cs similarity index 98% rename from NostrStreamer/Services/NostrListener.cs rename to NostrStreamer/Services/Background/NostrListener.cs index c744090..ae364fb 100644 --- a/NostrStreamer/Services/NostrListener.cs +++ b/NostrStreamer/Services/Background/NostrListener.cs @@ -5,7 +5,7 @@ using Nostr.Client.Communicator; using Nostr.Client.Requests; using Websocket.Client.Models; -namespace NostrStreamer.Services; +namespace NostrStreamer.Services.Background; public class NostrListener : IDisposable { diff --git a/NostrStreamer/Services/ThumbnailGenerator.cs b/NostrStreamer/Services/Background/ThumbnailGenerator.cs similarity index 93% rename from NostrStreamer/Services/ThumbnailGenerator.cs rename to NostrStreamer/Services/Background/ThumbnailGenerator.cs index 0a974fe..e939c39 100644 --- a/NostrStreamer/Services/ThumbnailGenerator.cs +++ b/NostrStreamer/Services/Background/ThumbnailGenerator.cs @@ -1,7 +1,8 @@ using Microsoft.EntityFrameworkCore; using NostrStreamer.Database; +using NostrStreamer.Services.Thumbnail; -namespace NostrStreamer.Services; +namespace NostrStreamer.Services.Background; public class ThumbnailGenerator : BackgroundService { @@ -22,7 +23,7 @@ public class ThumbnailGenerator : BackgroundService { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var gen = scope.ServiceProvider.GetRequiredService(); + var gen = scope.ServiceProvider.GetRequiredService(); var streams = await db.Streams .AsNoTracking() diff --git a/NostrStreamer/Services/Dvr/S3DvrStore.cs b/NostrStreamer/Services/Dvr/S3DvrStore.cs index ea33aec..4083db7 100644 --- a/NostrStreamer/Services/Dvr/S3DvrStore.cs +++ b/NostrStreamer/Services/Dvr/S3DvrStore.cs @@ -17,8 +17,8 @@ public class S3DvrStore : IDvrStore { _httpClient = httpClient; _logger = logger; - _config = config.DvrStore; - _client = config.DvrStore.CreateClient(); + _config = config.S3Store; + _client = config.S3Store.CreateClient(); } public async Task UploadRecording(UserStream stream, Uri source) diff --git a/NostrStreamer/Services/StreamEventBuilder.cs b/NostrStreamer/Services/StreamEventBuilder.cs index df0eee6..20c0f05 100644 --- a/NostrStreamer/Services/StreamEventBuilder.cs +++ b/NostrStreamer/Services/StreamEventBuilder.cs @@ -37,7 +37,7 @@ public class StreamEventBuilder new("d", stream.Id.ToString()), new("title", user.Title ?? ""), new("summary", user.Summary ?? ""), - new("image", new Uri(_config.DataHost, $"{stream.Id}.jpg").ToString()), + new("image", stream.Thumbnail ?? user.Image ?? ""), new("status", status), new("p", user.PubKey, "", "host"), new("relays", _config.Relays), diff --git a/NostrStreamer/Services/StreamManager/NostrStreamManager.cs b/NostrStreamer/Services/StreamManager/NostrStreamManager.cs index e237235..a9ed748 100644 --- a/NostrStreamer/Services/StreamManager/NostrStreamManager.cs +++ b/NostrStreamer/Services/StreamManager/NostrStreamManager.cs @@ -12,7 +12,6 @@ public class NostrStreamManager : IStreamManager private readonly StreamManagerContext _context; private readonly StreamEventBuilder _eventBuilder; private readonly IDvrStore _dvrStore; - private readonly ThumbnailService _thumbnailService; private readonly Config _config; public NostrStreamManager(ILogger logger, StreamManagerContext context, IServiceProvider serviceProvider) @@ -21,7 +20,6 @@ public class NostrStreamManager : IStreamManager _context = context; _eventBuilder = serviceProvider.GetRequiredService(); _dvrStore = serviceProvider.GetRequiredService(); - _thumbnailService = serviceProvider.GetRequiredService(); _config = serviceProvider.GetRequiredService(); } @@ -56,11 +54,8 @@ public class NostrStreamManager : IStreamManager { _logger.LogInformation("Stream started for: {pubkey}", _context.User.PubKey); TestCanStream(); - await UpdateStreamState(UserStreamState.Live); -#pragma warning disable CS4014 - Task.Run(async () => await _thumbnailService.GenerateThumb(_context.UserStream)); -#pragma warning restore CS4014 + await UpdateStreamState(UserStreamState.Live); } public async Task StreamStopped() diff --git a/NostrStreamer/Services/Thumbnail/BaseThumbnailService.cs b/NostrStreamer/Services/Thumbnail/BaseThumbnailService.cs new file mode 100644 index 0000000..4c6f4ec --- /dev/null +++ b/NostrStreamer/Services/Thumbnail/BaseThumbnailService.cs @@ -0,0 +1,29 @@ +using FFMpegCore; +using NostrStreamer.Database; + +namespace NostrStreamer.Services.Thumbnail; + +public abstract class BaseThumbnailService +{ + protected readonly ILogger Logger; + protected readonly Config Config; + + protected BaseThumbnailService(Config config, ILogger logger) + { + Config = config; + Logger = logger; + } + + protected async Task GenerateThumbnail(UserStream stream) + { + var path = Path.ChangeExtension(Path.GetTempFileName(), ".jpg"); + var cmd = FFMpegArguments + .FromUrlInput(new Uri(Config.RtmpHost, $"{stream.Endpoint.App}/source/{stream.User.StreamKey}?vhost=hls.zap.stream")) + .OutputToFile(path, true, o => { o.ForceFormat("image2").WithCustomArgument("-vframes 1"); }) + .CancellableThrough(new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token); + + Logger.LogInformation("Running command {cmd}", cmd.Arguments); + await cmd.ProcessAsynchronously(); + return path; + } +} diff --git a/NostrStreamer/Services/Thumbnail/IThumbnailService.cs b/NostrStreamer/Services/Thumbnail/IThumbnailService.cs new file mode 100644 index 0000000..90f9f87 --- /dev/null +++ b/NostrStreamer/Services/Thumbnail/IThumbnailService.cs @@ -0,0 +1,8 @@ +using NostrStreamer.Database; + +namespace NostrStreamer.Services.Thumbnail; + +public interface IThumbnailService +{ + Task GenerateThumb(UserStream stream); +} diff --git a/NostrStreamer/Services/Thumbnail/S3ThumbnailService.cs b/NostrStreamer/Services/Thumbnail/S3ThumbnailService.cs new file mode 100644 index 0000000..c67cae3 --- /dev/null +++ b/NostrStreamer/Services/Thumbnail/S3ThumbnailService.cs @@ -0,0 +1,80 @@ +using System.Diagnostics; +using Amazon.S3; +using Microsoft.EntityFrameworkCore; +using NostrStreamer.Database; + +namespace NostrStreamer.Services.Thumbnail; + +public class S3ThumbnailService : BaseThumbnailService, IThumbnailService +{ + private readonly AmazonS3Client _client; + private readonly StreamerContext _context; + + public S3ThumbnailService(Config config, ILogger logger, StreamerContext context) : base(config, logger) + { + _client = config.S3Store.CreateClient(); + _context = context; + } + + public async Task GenerateThumb(UserStream stream) + { + try + { + var sw = Stopwatch.StartNew(); + var path = await GenerateThumbnail(stream); + var tGen = sw.Elapsed; + var s3Path = MapPath(stream.Id); + + sw.Restart(); + await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read); + await _client.PutObjectAsync(new() + { + BucketName = Config.S3Store.BucketName, + Key = s3Path, + InputStream = fs, + AutoCloseStream = false, + AutoResetStreamPosition = false, + ContentType = "image/jpeg", + 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 tUpload = sw.Elapsed; + sw.Restart(); + await _context.Streams.Where(a => a.Id == stream.Id) + .ExecuteUpdateAsync(o => o.SetProperty(v => v.Thumbnail, ub.Uri.ToString())); + + var tDbUpdate = sw.Elapsed; + + stream.Thumbnail = ub.Uri.ToString(); + + fs.Close(); + File.Delete(path); + + Logger.LogInformation("{id} generated={tg:#,##0}ms, uploaded={tu:#,##0}ms, db={td:#,##0}ms", stream.Id, tGen.TotalMilliseconds, + tUpload.TotalMilliseconds, tDbUpdate.TotalMilliseconds); + } + catch (Exception ex) + { + Logger.LogWarning("Failed to generate {id} thumbnail {msg}", stream.Id, ex.Message); + } + } + + private string MapPath(Guid id) + { + return $"{id}/thumb.jpg"; + } +} diff --git a/NostrStreamer/Services/ThumbnailService.cs b/NostrStreamer/Services/ThumbnailService.cs deleted file mode 100644 index ff9b72c..0000000 --- a/NostrStreamer/Services/ThumbnailService.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Diagnostics; -using FFMpegCore; -using NostrStreamer.Database; - -namespace NostrStreamer.Services; - -public class ThumbnailService -{ - private const string Dir = "thumbs"; - private readonly Config _config; - private readonly ILogger _logger; - - public ThumbnailService(Config config, ILogger logger) - { - _config = config; - _logger = logger; - if (!Directory.Exists(Dir)) - { - Directory.CreateDirectory(Dir); - } - } - - public async Task GenerateThumb(UserStream stream) - { - var path = MapPath(stream.Id); - try - { - var sw = Stopwatch.StartNew(); - var cmd = FFMpegArguments - .FromUrlInput(new Uri(_config.RtmpHost, $"{stream.Endpoint.App}/source/{stream.User.StreamKey}?vhost=hls.zap.stream")) - .OutputToFile(path, true, o => { o.ForceFormat("image2").WithCustomArgument("-vframes 1"); }) - .CancellableThrough(new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token); - - _logger.LogInformation("Running command {cmd}", cmd.Arguments); - await cmd.ProcessAsynchronously(); - sw.Stop(); - _logger.LogInformation("Generated {id} thumb in {n:#,##0}ms", stream.Id, sw.Elapsed.TotalMilliseconds); - } - catch (Exception ex) - { - _logger.LogWarning("Failed to generate {id} thumbnail {msg}", stream.Id, ex.Message); - } - } - - public System.IO.Stream? GetThumbnail(Guid id) - { - var path = MapPath(id); - return File.Exists(path) ? new FileStream(path, FileMode.Open, FileAccess.Read) : null; - } - - private string MapPath(Guid id) - { - return Path.Combine(Dir, $"{id}.jpg"); - } -} diff --git a/NostrStreamer/appsettings.json b/NostrStreamer/appsettings.json index f29d132..7d6e822 100644 --- a/NostrStreamer/appsettings.json +++ b/NostrStreamer/appsettings.json @@ -27,7 +27,7 @@ "CertPath": "/Users/kieran/.polar/networks/1/volumes/lnd/bob/tls.cert", "MacaroonPath": "/Users/kieran/.polar/networks/1/volumes/lnd/bob/data/chain/bitcoin/regtest/admin.macaroon" }, - "DvrStore": { + "S3Store": { "ServiceUrl": "http://localhost:9010", "AccessKey": "TQcxug1ZAXfnZ5bvc9n5", "SecretKey": "p7EK4qew6DBkBPqrpRPuJgTOc6ChUlfIcEdAwE7K",