Commit: 3a545d7
Parent: 1ca8a7c

Store uploads in database

Mårten Åsberg committed on 2026-05-14 at 15:35
src/Api/Program.cs +1 -1
diff --git a/src/Api/Program.cs b/src/Api/Program.cs
index 88962c3..9395cc2 100644
@@ -30,7 +30,7 @@ builder.ConfigureOpenTelemetry();
builder.Services.AddOpenApi();
builder.Services.AddClipSelector().AddClipGenerator().AddCleaner();
builder.Services.AddClipSelector().AddClipGenerator().AddCleaner().AddUploader();
builder.Services.AddJellyfinDatabase().AddSlopperDatabase().AddFfmpegServices().AddAi().AddYouTubeUploader();
src/Api/Upload.cs +9 -0
diff --git a/src/Api/Upload.cs b/src/Api/Upload.cs
new file mode 100644
index 0000000..18fb3a1
@@ -0,0 +1,9 @@
using System;
namespace Slopper.Api;
public sealed record Upload(Uri CanonicalUrl, DateTimeOffset CreatedAt, string Platform)
{
public static Upload FromDomain(Domain.Upload upload) =>
new(upload.CanonicalUrl, upload.CreatedAt, upload.Platform);
}
src/Api/YouTubeApiEndpoints.cs +4 -22
diff --git a/src/Api/YouTubeApiEndpoints.cs b/src/Api/YouTubeApiEndpoints.cs
index 8ff365f..261f8a0 100644
@@ -1,5 +1,3 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
@@ -7,7 +5,6 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Slopper.Api.YouTubeAuth;
using Slopper.Domain;
@@ -31,27 +28,12 @@ public static class YouTubeApiEndpoints
private static RedirectHttpResult Login() => TypedResults.Redirect("/admin/youtube");
private static async Task<Results<Ok<UploadResult>, NotFound>> Upload(
[FromKeyedServices("YouTube")] IUploader uploader,
[FromServices] IClipRepository clipRepository,
private static async Task<Results<Ok<Upload>, NotFound>> Upload(
[FromServices] Uploader uploader,
CancellationToken cancellationToken
)
{
var clip = await clipRepository
.GetLatest(limit: 1, cancellationToken: cancellationToken)
.FirstOrDefaultAsync(cancellationToken);
if (clip is null)
{
return TypedResults.NotFound();
}
var result = await uploader.Upload(clip, cancellationToken);
return TypedResults.Ok(UploadResult.FromDomain(result));
var result = await uploader.Upload("YouTube", cancellationToken);
return result is null ? TypedResults.NotFound() : TypedResults.Ok(Api.Upload.FromDomain(result));
}
}
public sealed record UploadResult(Uri CanonicalUrl)
{
public static UploadResult FromDomain(Domain.UploadResult uploadResult) => new(uploadResult.CanonicalUrl);
}
src/Domain/Clip.cs +1 -0
diff --git a/src/Domain/Clip.cs b/src/Domain/Clip.cs
index fbdf445..b85bc07 100644
@@ -14,5 +14,6 @@ public sealed record Clip(
)
{
public required IReadOnlySet<Tag> Tags { get; init; }
public ICollection<Upload> Uploads { get; init; } = [];
public DateTimeOffset? RemovedAt { get; set; }
}
src/Domain/IUploader.cs +1 -4
diff --git a/src/Domain/IUploader.cs b/src/Domain/IUploader.cs
index 3f6ee3c..bb51fc5 100644
@@ -1,4 +1,3 @@
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -6,7 +5,5 @@ namespace Slopper.Domain;
public interface IUploader
{
Task<UploadResult> Upload(Clip clip, CancellationToken cancellationToken);
Task<Upload> Upload(Clip clip, CancellationToken cancellationToken);
}
public sealed record UploadResult(Uri CanonicalUrl);
src/Domain/Upload.cs +5 -0
diff --git a/src/Domain/Upload.cs b/src/Domain/Upload.cs
new file mode 100644
index 0000000..a2ac634
@@ -0,0 +1,5 @@
using System;
namespace Slopper.Domain;
public sealed record Upload(Uri CanonicalUrl, DateTimeOffset CreatedAt, string Platform);
src/Domain/Uploader.cs +41 -0
diff --git a/src/Domain/Uploader.cs b/src/Domain/Uploader.cs
new file mode 100644
index 0000000..3065f59
@@ -0,0 +1,41 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
namespace Slopper.Domain;
public sealed class Uploader(IServiceProvider serviceProvider, IClipRepository clipRepository)
{
public async Task<Upload?> Upload(string platform, CancellationToken cancellationToken)
{
var uploader = serviceProvider.GetRequiredKeyedService<IUploader>(platform);
var clip = await clipRepository
.GetLatest(limit: 1, cancellationToken: cancellationToken)
.FirstOrDefaultAsync(cancellationToken);
if (clip is null)
return null;
var upload = await uploader.Upload(clip, cancellationToken);
clip.Uploads.Add(upload);
await clipRepository.Save(clip, cancellationToken);
return upload;
}
}
public static class UploaderServiceCollectionExtensions
{
extension(IServiceCollection services)
{
public IServiceCollection AddUploader()
{
services.AddTransient<Uploader>();
return services;
}
}
}
src/Infrastructure/Database/Slopper/ClipRepository.cs +3 -6
diff --git a/src/Infrastructure/Database/Slopper/ClipRepository.cs b/src/Infrastructure/Database/Slopper/ClipRepository.cs
index 2c3fda3..c212186 100644
@@ -14,6 +14,7 @@ internal sealed class ClipRepository(SlopperDbContext slopperContext) : IClipRep
{
IQueryable<Clip> query = slopperContext
.Clips.Include(c => c.Tags)
.Include(c => c.Uploads)
.Where(c => c.RemovedAt == null)
.OrderByDescending(c => c.Id);
if (after is Guid afterKey)
@@ -26,6 +27,7 @@ internal sealed class ClipRepository(SlopperDbContext slopperContext) : IClipRep
public IAsyncEnumerable<Clip> GetCreatedBefore(DateTimeOffset before, CancellationToken cancellationToken) =>
slopperContext
.Clips.Include(c => c.Tags)
.Include(c => c.Uploads)
.Where(c => c.CreatedAt < before && c.RemovedAt == null)
.AsAsyncEnumerable();
@@ -34,12 +36,7 @@ internal sealed class ClipRepository(SlopperDbContext slopperContext) : IClipRep
public async Task Save(Clip clip, CancellationToken cancellationToken)
{
var entry = slopperContext.Entry(clip);
if (entry.State is EntityState.Unchanged)
{
return;
}
if (entry.State is EntityState.Detached)
if (slopperContext.Entry(clip).State is EntityState.Detached)
{
slopperContext.Add(clip);
}
src/Infrastructure/Database/Slopper/Migrations/20260514132425_AddUploads.Designer.cs +111 -0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/20260514132425_AddUploads.Designer.cs b/src/Infrastructure/Database/Slopper/Migrations/20260514132425_AddUploads.Designer.cs
new file mode 100644
index 0000000..cb14d70
@@ -0,0 +1,111 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Slopper.Infrastructure.Database.Slopper;
#nullable disable
namespace Slopper.Infrastructure.Database.Slopper.Migrations
{
[DbContext(typeof(SlopperDbContext))]
[Migration("20260514132425_AddUploads")]
partial class AddUploads
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("Slopper.Domain.Clip", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Caption")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<TimeSpan>("Duration")
.HasColumnType("TEXT");
b.Property<Guid>("MediaItemId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("TEXT");
b.Property<TimeSpan>("Start")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Clips");
});
modelBuilder.Entity("Slopper.Domain.Clip", b =>
{
b.OwnsMany("Slopper.Domain.Tag", "Tags", b1 =>
{
b1.Property<Guid>("ClipId")
.HasColumnType("TEXT");
b1.Property<string>("Value")
.HasColumnType("TEXT");
b1.HasKey("ClipId", "Value");
b1.ToTable("Tag");
b1.WithOwner()
.HasForeignKey("ClipId");
});
b.OwnsMany("Slopper.Domain.Upload", "Uploads", b1 =>
{
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<string>("CanonicalUrl")
.IsRequired()
.HasColumnType("TEXT");
b1.Property<Guid>("ClipId")
.HasColumnType("TEXT");
b1.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b1.Property<string>("Platform")
.IsRequired()
.HasColumnType("TEXT");
b1.HasKey("Id");
b1.HasIndex("ClipId");
b1.ToTable("Upload");
b1.WithOwner()
.HasForeignKey("ClipId");
});
b.Navigation("Tags");
b.Navigation("Uploads");
});
#pragma warning restore 612, 618
}
}
}
src/Infrastructure/Database/Slopper/Migrations/20260514132425_AddUploads.cs +46 -0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/20260514132425_AddUploads.cs b/src/Infrastructure/Database/Slopper/Migrations/20260514132425_AddUploads.cs
new file mode 100644
index 0000000..df32af5
@@ -0,0 +1,46 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Slopper.Infrastructure.Database.Slopper.Migrations
{
/// <inheritdoc />
public partial class AddUploads : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Upload",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false).Annotation("Sqlite:Autoincrement", true),
CanonicalUrl = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Platform = table.Column<string>(type: "TEXT", nullable: false),
ClipId = table.Column<Guid>(type: "TEXT", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_Upload", x => x.Id);
table.ForeignKey(
name: "FK_Upload_Clips_ClipId",
column: x => x.ClipId,
principalTable: "Clips",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(name: "IX_Upload_ClipId", table: "Upload", column: "ClipId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Upload");
}
}
}
src/Infrastructure/Database/Slopper/Migrations/SlopperDbContextModelSnapshot.cs +32 -0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/SlopperDbContextModelSnapshot.cs b/src/Infrastructure/Database/Slopper/Migrations/SlopperDbContextModelSnapshot.cs
index ffab032..a084a39 100644
@@ -68,7 +68,39 @@ namespace Slopper.Infrastructure.Database.Slopper.Migrations
.HasForeignKey("ClipId");
});
b.OwnsMany("Slopper.Domain.Upload", "Uploads", b1 =>
{
b1.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<string>("CanonicalUrl")
.IsRequired()
.HasColumnType("TEXT");
b1.Property<Guid>("ClipId")
.HasColumnType("TEXT");
b1.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b1.Property<string>("Platform")
.IsRequired()
.HasColumnType("TEXT");
b1.HasKey("Id");
b1.HasIndex("ClipId");
b1.ToTable("Upload");
b1.WithOwner()
.HasForeignKey("ClipId");
});
b.Navigation("Tags");
b.Navigation("Uploads");
});
#pragma warning restore 612, 618
}
src/Infrastructure/Database/Slopper/SlopperDbContext.cs +10 -0
diff --git a/src/Infrastructure/Database/Slopper/SlopperDbContext.cs b/src/Infrastructure/Database/Slopper/SlopperDbContext.cs
index 71cc98d..7507cfa 100644
@@ -1,3 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore;
using Slopper.Domain;
@@ -17,5 +18,14 @@ internal sealed class SlopperDbContext(DbContextOptions options) : DbContext(opt
clipBuilder.Property(c => c.Caption);
clipBuilder.Property(c => c.RemovedAt);
clipBuilder.OwnsMany(c => c.Tags).HasKey("ClipId", "Value");
clipBuilder.OwnsMany(
c => c.Uploads,
b =>
{
b.HasKey("Id");
b.Property("Id").ValueGeneratedOnAdd();
b.Property(u => u.CanonicalUrl).HasConversion(u => u.ToString(), s => new Uri(s));
}
);
}
}
src/Infrastructure/YouTube/YouTubeUploader.cs +13 -6
diff --git a/src/Infrastructure/YouTube/YouTubeUploader.cs b/src/Infrastructure/YouTube/YouTubeUploader.cs
index 79a076f..278ada5 100644
@@ -12,7 +12,7 @@ namespace Slopper.Infrastructure.YouTube;
internal sealed class YouTubeUploader(YouTubeService youTubeService) : IUploader
{
public async Task<UploadResult> Upload(Clip clip, CancellationToken cancellationToken)
public async Task<Upload> Upload(Clip clip, CancellationToken cancellationToken)
{
var video = new Video()
{
@@ -25,20 +25,27 @@ internal sealed class YouTubeUploader(YouTubeService youTubeService) : IUploader
Status = new() { PrivacyStatus = "public" },
};
string? youTubeId = null;
(string, DateTimeOffset)? result = null;
using (var videoStream = File.OpenRead(clip.Path))
{
var request = youTubeService.Videos.Insert(video, "snippet,status", videoStream, "video/mp4");
request.ResponseReceived += v => youTubeId = v.Id;
request.ResponseReceived += v =>
{
if (v.Snippet.PublishedAtDateTimeOffset is not { } createdAt)
{
throw new Exception("Received no published at datetime from YouTube API");
}
result = (v.Id, createdAt);
};
var progress = await request.UploadAsync(cancellationToken);
progress.ThrowOnFailure();
}
if (youTubeId is null)
if (result is not var (id, createdAt))
{
throw new Exception("Received no YouTube ID from upload");
throw new Exception("Received no result from YouTube upload");
}
return new(new($"https://www.youtube.com/shorts/{youTubeId}"));
return new(new($"https://www.youtube.com/shorts/{id}"), createdAt, "YouTube");
}
}