src/Api/Upload.cs
+2
-2
diff --git a/src/Api/Upload.cs b/src/Api/Upload.cs
index 18fb3a1..678f129 100644
@@ -2,8 +2,8 @@ using System;
namespace Slopper.Api;
public sealed record Upload(Uri CanonicalUrl, DateTimeOffset CreatedAt, string Platform)
public sealed record Upload(Uri CanonicalUrl, DateTimeOffset CreatedAt, DateTimeOffset PublishedAt, string Platform)
{
public static Upload FromDomain(Domain.Upload upload) =>
new(upload.CanonicalUrl, upload.CreatedAt, upload.Platform);
new(upload.CanonicalUrl, upload.CreatedAt, upload.PublishedAt, upload.Platform);
}
src/Api/appsettings.Development.json
+3
-0
diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json
index 11124e7..271e1d3 100644
@@ -12,6 +12,9 @@
"Cleaner": {
"Retention": "00:05:00"
},
"Uploader": {
"PublishInterval": "00:00:01"
},
"ClipDescriber": {
"Prompt": "Give one short sentence caption and tags for the attached frames from a video clip, optimized for engagement on TikTok and Instagram Reels. Answer with JSON, one string field for `caption` and an array of strings for `tags`. Make the caption one sentence only optimized for engagement, and the tags one word or camel case without the hashtag symbol."
},
src/Cli/appsettings.Development.json
+3
-0
diff --git a/src/Cli/appsettings.Development.json b/src/Cli/appsettings.Development.json
index 1d0bff2..4887028 100644
@@ -8,6 +8,9 @@
"Cleaner": {
"Retention": "00:05:00"
},
"Uploader": {
"PublishInterval": "00:00:01"
},
"ClipDescriber": {
"Prompt": "Give one short sentence caption and tags for the attached frames from a video clip, optimized for engagement on TikTok and Instagram Reels. Answer with JSON, one string field for `caption` and an array of plain strings for `tags` with at least 2 values. Make the caption one sentence only optimized for engagement, and the tags one word or camel case without the hashtag symbol."
},
src/Domain/IUploader.cs
+2
-1
diff --git a/src/Domain/IUploader.cs b/src/Domain/IUploader.cs
index bb51fc5..354bedc 100644
@@ -1,3 +1,4 @@
using System;
using System.Threading;
using System.Threading.Tasks;
@@ -5,5 +6,5 @@ namespace Slopper.Domain;
public interface IUploader
{
Task<Upload> Upload(Clip clip, CancellationToken cancellationToken);
Task<Upload> Upload(Clip clip, DateTimeOffset publishAt, CancellationToken cancellationToken);
}
src/Domain/Upload.cs
+1
-1
diff --git a/src/Domain/Upload.cs b/src/Domain/Upload.cs
index a2ac634..dc7228a 100644
@@ -2,4 +2,4 @@ using System;
namespace Slopper.Domain;
public sealed record Upload(Uri CanonicalUrl, DateTimeOffset CreatedAt, string Platform);
public sealed record Upload(Uri CanonicalUrl, DateTimeOffset CreatedAt, DateTimeOffset PublishedAt, string Platform);
src/Domain/Uploader.cs
+27
-2
diff --git a/src/Domain/Uploader.cs b/src/Domain/Uploader.cs
index 9be80b2..16a8530 100644
@@ -1,31 +1,56 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
namespace Slopper.Domain;
public sealed class Uploader(IServiceProvider serviceProvider, IClipRepository clipRepository)
public sealed class Uploader(
IServiceProvider serviceProvider,
IOptionsMonitor<UploaderOptions> options,
TimeProvider timeProvider,
IClipRepository clipRepository
)
{
public async Task Upload(string platform, CancellationToken cancellationToken)
{
var uploader = serviceProvider.GetRequiredKeyedService<IUploader>(platform);
var publishAt = timeProvider.GetUtcNow();
var interval = options.CurrentValue.PublishInterval;
await foreach (var clip in clipRepository.GetNotUploadedTo(platform, cancellationToken))
{
var upload = await uploader.Upload(clip, cancellationToken);
var upload = await uploader.Upload(clip, publishAt, cancellationToken);
clip.Uploads.Add(upload);
await clipRepository.Save(clip, cancellationToken);
publishAt += interval;
}
}
}
public sealed class UploaderOptions
{
[Required]
public required TimeSpan PublishInterval { get; set; }
}
[OptionsValidator]
public sealed partial class UploaderOptionsValidator : IValidateOptions<UploaderOptions>;
public static class UploaderServiceCollectionExtensions
{
extension(IServiceCollection services)
{
public IServiceCollection AddUploader()
{
services.AddOptions<UploaderOptions>().BindConfiguration("Uploader").ValidateOnStart();
services.AddTransient<IValidateOptions<UploaderOptions>, UploaderOptionsValidator>();
services.TryAddSingleton(TimeProvider.System);
services.AddTransient<Uploader>();
return services;
}
src/Frontend/openapi.json
+221
-0
diff --git a/src/Frontend/openapi.json b/src/Frontend/openapi.json
new file mode 100644
index 0000000..4e5d5f9
@@ -0,0 +1,221 @@
{
"openapi": "3.1.2",
"info": {
"title": "Slopper.Api | v1",
"version": "1.0.0"
},
"servers": [
{
"url": "http://localhost:5055"
}
],
"paths": {
"/api/clips": {
"get": {
"tags": [
"ApiEndpoints"
],
"parameters": [
{
"name": "after",
"in": "query",
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "limit",
"in": "query",
"schema": {
"maximum": 64,
"minimum": 0,
"pattern": "^-?(?:0|[1-9]\\d*)$",
"type": [
"integer",
"string"
],
"format": "int32",
"default": 10
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Clip"
}
}
}
}
}
}
}
},
"/api/clips/{id}/stream": {
"get": {
"tags": [
"ApiEndpoints"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"404": {
"description": "Not Found"
}
}
}
},
"/api/jobs": {
"get": {
"tags": [
"ApiEndpoints"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobStatus"
}
}
}
}
}
},
"put": {
"tags": [
"ApiEndpoints"
],
"summary": "Triggers a job generating a new clip.",
"responses": {
"202": {
"description": "Accepted"
}
}
}
},
"/api/youtube/login": {
"get": {
"tags": [
"YouTubeApiEndpoints"
],
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/api/youtube/upload": {
"get": {
"tags": [
"YouTubeApiEndpoints"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobStatus"
}
}
}
}
}
},
"put": {
"tags": [
"YouTubeApiEndpoints"
],
"responses": {
"202": {
"description": "Accepted"
}
}
}
}
},
"components": {
"schemas": {
"Clip": {
"required": [
"id",
"duration",
"createdAt",
"caption",
"tags"
],
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"duration": {
"pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$",
"type": "string"
},
"createdAt": {
"type": "string",
"format": "date-time"
},
"caption": {
"type": [
"null",
"string"
]
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"JobStatus": {
"required": [
"isRunning"
],
"type": "object",
"properties": {
"isRunning": {
"type": "boolean"
},
"nextScheduledRun": {
"type": [
"null",
"string"
],
"format": "date-time"
}
}
}
}
},
"tags": [
{
"name": "ApiEndpoints"
},
{
"name": "YouTubeApiEndpoints"
}
]
}
src/Infrastructure/Database/Slopper/Migrations/20260514172239_AddPublishedAt.Designer.cs
+114
-0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/20260514172239_AddPublishedAt.Designer.cs b/src/Infrastructure/Database/Slopper/Migrations/20260514172239_AddPublishedAt.Designer.cs
new file mode 100644
index 0000000..75c4de5
@@ -0,0 +1,114 @@
// <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("20260514172239_AddPublishedAt")]
partial class AddPublishedAt
{
/// <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.Property<DateTimeOffset>("PublishedAt")
.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/20260514172239_AddPublishedAt.cs
+32
-0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/20260514172239_AddPublishedAt.cs b/src/Infrastructure/Database/Slopper/Migrations/20260514172239_AddPublishedAt.cs
new file mode 100644
index 0000000..58e9a89
@@ -0,0 +1,32 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Slopper.Infrastructure.Database.Slopper.Migrations
{
/// <inheritdoc />
public partial class AddPublishedAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "PublishedAt",
table: "Upload",
type: "TEXT",
nullable: false,
defaultValue: new DateTimeOffset(
new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
new TimeSpan(0, 0, 0, 0, 0)
)
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "PublishedAt", table: "Upload");
}
}
}
src/Infrastructure/Database/Slopper/Migrations/SlopperDbContextModelSnapshot.cs
+3
-0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/SlopperDbContextModelSnapshot.cs b/src/Infrastructure/Database/Slopper/Migrations/SlopperDbContextModelSnapshot.cs
index a084a39..f61db87 100644
@@ -88,6 +88,9 @@ namespace Slopper.Infrastructure.Database.Slopper.Migrations
.IsRequired()
.HasColumnType("TEXT");
b1.Property<DateTimeOffset>("PublishedAt")
.HasColumnType("TEXT");
b1.HasKey("Id");
b1.HasIndex("ClipId");
src/Infrastructure/YouTube/YouTubeUploader.cs
+7
-7
diff --git a/src/Infrastructure/YouTube/YouTubeUploader.cs b/src/Infrastructure/YouTube/YouTubeUploader.cs
index 278ada5..575fa7b 100644
@@ -10,9 +10,9 @@ using Slopper.Domain;
namespace Slopper.Infrastructure.YouTube;
internal sealed class YouTubeUploader(YouTubeService youTubeService) : IUploader
internal sealed class YouTubeUploader(TimeProvider timeProvider, YouTubeService youTubeService) : IUploader
{
public async Task<Upload> Upload(Clip clip, CancellationToken cancellationToken)
public async Task<Upload> Upload(Clip clip, DateTimeOffset publishAt, CancellationToken cancellationToken)
{
var video = new Video()
{
@@ -22,7 +22,7 @@ internal sealed class YouTubeUploader(YouTubeService youTubeService) : IUploader
Tags = [.. clip.Tags.Select(t => t.Value)],
CategoryId = "1",
},
Status = new() { PrivacyStatus = "public" },
Status = new() { PrivacyStatus = "private", PublishAtDateTimeOffset = publishAt },
};
(string, DateTimeOffset)? result = null;
@@ -31,21 +31,21 @@ internal sealed class YouTubeUploader(YouTubeService youTubeService) : IUploader
var request = youTubeService.Videos.Insert(video, "snippet,status", videoStream, "video/mp4");
request.ResponseReceived += v =>
{
if (v.Snippet.PublishedAtDateTimeOffset is not { } createdAt)
if (v.Snippet.PublishedAtDateTimeOffset is not { } publishedAt)
{
throw new Exception("Received no published at datetime from YouTube API");
}
result = (v.Id, createdAt);
result = (v.Id, publishedAt);
};
var progress = await request.UploadAsync(cancellationToken);
progress.ThrowOnFailure();
}
if (result is not var (id, createdAt))
if (result is not var (id, publishedAt))
{
throw new Exception("Received no result from YouTube upload");
}
return new(new($"https://www.youtube.com/shorts/{id}"), createdAt, "YouTube");
return new(new($"https://www.youtube.com/shorts/{id}"), timeProvider.GetUtcNow(), publishedAt, "YouTube");
}
}