src/Api/CleanupJob.cs
+37
-0
diff --git a/src/Api/CleanupJob.cs b/src/Api/CleanupJob.cs
new file mode 100644
index 0000000..bcc5ca6
@@ -0,0 +1,37 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Quartz;
using Slopper.Domain;
namespace Slopper.Api;
[DisallowConcurrentExecution]
public sealed class CleanupJob(ILogger<CleanupJob> logger, Cleaner cleaner) : IJob
{
public static JobKey Key { get; } = new(nameof(CleanupJob));
public async Task Execute(IJobExecutionContext context)
{
logger.LogDebug("Running cleanup job");
try
{
await cleaner.Cleanup(context.CancellationToken);
}
catch (Exception ex) when (!context.CancellationToken.IsCancellationRequested)
{
throw new JobExecutionException(ex, refireImmediately: false);
}
}
}
public static class CleanupJobExtensions
{
extension(IServiceCollectionQuartzConfigurator quartz)
{
public IServiceCollectionQuartzConfigurator AddCleanupJob() =>
quartz
.AddJob<CleanupJob>(CleanupJob.Key, options => options.StoreDurably())
.AddTrigger(options => options.ForJob(CleanupJob.Key).WithCronSchedule("H H * * * ?"));
}
}
src/Api/ClipGenerationJob.cs
+3
-4
diff --git a/src/Api/ClipGenerationJob.cs b/src/Api/ClipGenerationJob.cs
index e7b0b18..4f2ddf3 100644
@@ -47,10 +47,9 @@ public static class ClipGenerationJobExtensions
extension(IServiceCollectionQuartzConfigurator quartz)
{
public IServiceCollectionQuartzConfigurator AddClipGenerationJob() =>
quartz.AddJob<ClipGenerationJob>(ClipGenerationJob.Key, options => options.StoreDurably());
public IServiceCollectionQuartzConfigurator AddClipGenerationJobTrigger() =>
quartz.AddTrigger(options => options.ForJob(ClipGenerationJob.Key).StartNow());
quartz
.AddJob<ClipGenerationJob>(ClipGenerationJob.Key, options => options.StoreDurably())
.AddTrigger(options => options.ForJob(ClipGenerationJob.Key).StartNow());
}
extension(IServiceCollection services)
src/Api/Program.cs
+2
-2
diff --git a/src/Api/Program.cs b/src/Api/Program.cs
index b1b3b3e..0582857 100644
@@ -24,13 +24,13 @@ builder.ConfigureOpenTelemetry();
builder.Services.AddOpenApi();
builder.Services.AddClipSelector().AddClipGenerator();
builder.Services.AddClipSelector().AddClipGenerator().AddCleaner();
builder.Services.AddJellyfinDatabase().AddSlopperDatabase().AddFfmpegServices().AddAi();
builder
.Services.AddClipGenerationJobOptions()
.AddQuartz(quartz => quartz.AddClipGenerationJob().AddClipGenerationJobTrigger())
.AddQuartz(quartz => quartz.AddClipGenerationJob().AddCleanupJob())
.AddQuartzServer();
using var app = builder.Build();
src/Api/appsettings.Development.json
+3
-0
diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json
index 5d2cecc..7250c32 100644
@@ -9,6 +9,9 @@
"ClipGenerationJob": {
"Interval": "00:00:10.000"
},
"Cleaner": {
"Retention": "00:05:00"
},
"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/Program.cs
+1
-36
diff --git a/src/Cli/Program.cs b/src/Cli/Program.cs
index 3e5d32b..ec75654 100644
@@ -1,12 +1,6 @@
using System;
using System.IO;
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using Slopper.Cli;
using Slopper.Domain;
using Slopper.Domain.Describer;
using Slopper.Infrastructure.Ai;
using Slopper.Infrastructure.Database;
using Slopper.Infrastructure.Ffmpeg;
@@ -23,33 +17,4 @@ using var app = builder.Build();
await app.StartAsync();
var logger = app.Services.GetRequiredService<ILogger<Program>>();
using var scope = app.Services.CreateScope();
var timeProvider = scope.ServiceProvider.GetRequiredService<TimeProvider>();
var clipSelector = scope.ServiceProvider.GetRequiredService<ClipSelector>();
var clipDescriber = scope.ServiceProvider.GetRequiredService<ClipDescriber>();
var clipExtractor = scope.ServiceProvider.GetRequiredService<IClipExtractor>();
var clipRepository = scope.ServiceProvider.GetRequiredService<IClipRepository>();
var utcNow = timeProvider.GetUtcNow();
var media = new MediaItem(Guid.CreateVersion7(utcNow), args[0], new Subtitles.Embedded(2));
var (start, duration) = await clipSelector.PickClip(media, CancellationToken.None);
var clipId = Guid.CreateVersion7(utcNow);
var clipPath = Path.Join(args[1], $"{clipId}.mp4");
var descriptionTask = clipDescriber.DescribeClip(media, start, duration, CancellationToken.None);
await clipExtractor.ExtractClip(media, start, duration, clipPath, CancellationToken.None);
var description = await descriptionTask;
var clip = new Clip(clipId, media.Id, clipPath, start, duration, utcNow, description.Caption)
{
Tags = description.Tags,
};
await clipRepository.Save(clip, CancellationToken.None);
await app.StopAsync();
src/Cli/appsettings.Development.json
+3
-0
diff --git a/src/Cli/appsettings.Development.json b/src/Cli/appsettings.Development.json
index 204e8d2..1d0bff2 100644
@@ -5,6 +5,9 @@
"Slopper": "Debug"
}
},
"Cleaner": {
"Retention": "00:05:00"
},
"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/Cleaner.cs
+50
-0
diff --git a/src/Domain/Cleaner.cs b/src/Domain/Cleaner.cs
new file mode 100644
index 0000000..68af05e
@@ -0,0 +1,50 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Slopper.Domain;
public sealed class Cleaner(
IOptionsMonitor<CleanerOptions> options,
IClipRepository clipRepository,
TimeProvider timeProvider
)
{
public async Task Cleanup(CancellationToken cancellationToken)
{
var cutoff = timeProvider.GetUtcNow() - options.CurrentValue.Retention;
await foreach (var clip in clipRepository.GetCreatedBefore(cutoff, cancellationToken))
{
File.Delete(clip.Path);
clip.RemovedAt = timeProvider.GetUtcNow();
await clipRepository.Save(clip, cancellationToken);
}
}
}
public sealed class CleanerOptions
{
[Required]
public required TimeSpan Retention { get; init; }
}
[OptionsValidator]
internal sealed partial class CleanerOptionsValidator : IValidateOptions<CleanerOptions>;
public static class CleanerServiceCollectionExtensions
{
extension(IServiceCollection services)
{
public IServiceCollection AddCleaner()
{
services.AddOptions<CleanerOptions>().BindConfiguration("Cleaner").ValidateOnStart();
services.AddTransient<IValidateOptions<CleanerOptions>, CleanerOptionsValidator>();
services.AddTransient<Cleaner>();
return services;
}
}
}
src/Domain/Clip.cs
+1
-0
diff --git a/src/Domain/Clip.cs b/src/Domain/Clip.cs
index d648c0e..fbdf445 100644
@@ -14,4 +14,5 @@ public sealed record Clip(
)
{
public required IReadOnlySet<Tag> Tags { get; init; }
public DateTimeOffset? RemovedAt { get; set; }
}
src/Domain/IClipRepository.cs
+2
-0
diff --git a/src/Domain/IClipRepository.cs b/src/Domain/IClipRepository.cs
index 5f0b6aa..4d698c2 100644
@@ -9,6 +9,8 @@ public interface IClipRepository
{
IAsyncEnumerable<Clip> GetLatest(Guid? after = null, int limit = 10, CancellationToken cancellationToken = default);
IAsyncEnumerable<Clip> GetCreatedBefore(DateTimeOffset before, CancellationToken cancellationToken = default);
Task<Clip?> Get(Guid id, CancellationToken cancellationToken);
Task Save(Clip clip, CancellationToken cancellationToken);
src/Infrastructure/Database/Slopper/ClipRepository.cs
+19
-2
diff --git a/src/Infrastructure/Database/Slopper/ClipRepository.cs b/src/Infrastructure/Database/Slopper/ClipRepository.cs
index b6d0c5a..2c3fda3 100644
@@ -12,7 +12,10 @@ internal sealed class ClipRepository(SlopperDbContext slopperContext) : IClipRep
{
public IAsyncEnumerable<Clip> GetLatest(Guid? after, int limit, CancellationToken cancellationToken)
{
IQueryable<Clip> query = slopperContext.Clips.Include(c => c.Tags).OrderByDescending(c => c.Id);
IQueryable<Clip> query = slopperContext
.Clips.Include(c => c.Tags)
.Where(c => c.RemovedAt == null)
.OrderByDescending(c => c.Id);
if (after is Guid afterKey)
{
query = query.Where(q => q.Id < afterKey);
@@ -20,12 +23,26 @@ internal sealed class ClipRepository(SlopperDbContext slopperContext) : IClipRep
return query.Take(limit).AsAsyncEnumerable();
}
public IAsyncEnumerable<Clip> GetCreatedBefore(DateTimeOffset before, CancellationToken cancellationToken) =>
slopperContext
.Clips.Include(c => c.Tags)
.Where(c => c.CreatedAt < before && c.RemovedAt == null)
.AsAsyncEnumerable();
public async Task<Clip?> Get(Guid id, CancellationToken cancellationToken) =>
await slopperContext.Clips.Include(c => c.Tags).SingleOrDefaultAsync(c => c.Id == id, cancellationToken);
public async Task Save(Clip clip, CancellationToken cancellationToken)
{
slopperContext.Clips.Add(clip);
var entry = slopperContext.Entry(clip);
if (entry.State is EntityState.Unchanged)
{
return;
}
if (entry.State is EntityState.Detached)
{
slopperContext.Add(clip);
}
await slopperContext.SaveChangesAsync(cancellationToken);
}
}
src/Infrastructure/Database/Slopper/Migrations/20260511134116_AddClipRemovedAt.Designer.cs
+79
-0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/20260511134116_AddClipRemovedAt.Designer.cs b/src/Infrastructure/Database/Slopper/Migrations/20260511134116_AddClipRemovedAt.Designer.cs
new file mode 100644
index 0000000..42646ae
@@ -0,0 +1,79 @@
// <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("20260511134116_AddClipRemovedAt")]
partial class AddClipRemovedAt
{
/// <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.Navigation("Tags");
});
#pragma warning restore 612, 618
}
}
}
src/Infrastructure/Database/Slopper/Migrations/20260511134116_AddClipRemovedAt.cs
+23
-0
diff --git a/src/Infrastructure/Database/Slopper/Migrations/20260511134116_AddClipRemovedAt.cs b/src/Infrastructure/Database/Slopper/Migrations/20260511134116_AddClipRemovedAt.cs
new file mode 100644
index 0000000..445e5e0
@@ -0,0 +1,23 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Slopper.Infrastructure.Database.Slopper.Migrations
{
/// <inheritdoc />
public partial class AddClipRemovedAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(name: "RemovedAt", table: "Clips", type: "TEXT", nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "RemovedAt", table: "Clips");
}
}
}
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 7d416b2..ffab032 100644
@@ -39,6 +39,9 @@ namespace Slopper.Infrastructure.Database.Slopper.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("TEXT");
b.Property<TimeSpan>("Start")
.HasColumnType("TEXT");
src/Infrastructure/Database/Slopper/SlopperDbContext.cs
+1
-0
diff --git a/src/Infrastructure/Database/Slopper/SlopperDbContext.cs b/src/Infrastructure/Database/Slopper/SlopperDbContext.cs
index 0ea8f8d..71cc98d 100644
@@ -15,6 +15,7 @@ internal sealed class SlopperDbContext(DbContextOptions options) : DbContext(opt
clipBuilder.Property(c => c.Path);
clipBuilder.Property(c => c.CreatedAt);
clipBuilder.Property(c => c.Caption);
clipBuilder.Property(c => c.RemovedAt);
clipBuilder.OwnsMany(c => c.Tags).HasKey("ClipId", "Value");
}
}