Directory.Packages.props
+8
-17
diff --git a/Directory.Packages.props b/Directory.Packages.props
index fa541e8..e016f71 100644
@@ -19,31 +19,22 @@
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.2" />
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.5.2" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="11.0.0-preview.3.26207.106" />
<PackageVersion
Include="Microsoft.Extensions.Hosting.Abstractions"
Version="11.0.0-preview.3.26207.106"
/>
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="11.0.0-preview.3.26207.106" />
<PackageVersion
Include="Microsoft.Extensions.Logging.Abstractions"
Version="11.0.0-preview.3.26207.106"
/>
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="11.0.0-preview.3.26207.106" />
<PackageVersion
Include="Microsoft.Extensions.Options.ConfigurationExtensions"
Version="11.0.0-preview.3.26207.106"
/>
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="11.0.0-preview.3.26207.106" />
<PackageVersion Include="OllamaSharp" Version="5.4.25" />
<PackageVersion Include="OpenTelemetry" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion
Include="OpenTelemetry.Instrumentation.EntityFrameworkCore"
Version="1.15.1-beta.1"
/>
<PackageVersion Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" Version="1.15.1-beta.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Quartz" Version="1.15.1-beta.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="Quartz" Version="3.18.1" />
<PackageVersion Include="Quartz.AspNetCore" Version="3.18.1" />
<PackageVersion Include="SubtitlesParserV2" Version="2.4.0" />
</ItemGroup>
</Project>
</Project>
\ No newline at end of file
src/Api/Api.csproj
+3
-0
diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj
index 8e6ab2e..7d78a9c 100644
@@ -17,6 +17,9 @@
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.EntityFrameworkCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Quartz" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="Quartz" />
<PackageReference Include="Quartz.AspNetCore" />
</ItemGroup>
</Project>
src/Api/ApiEndpoints.cs
+69
-8
diff --git a/src/Api/ApiEndpoints.cs b/src/Api/ApiEndpoints.cs
index e9fe4af..06bc105 100644
@@ -2,12 +2,15 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Quartz;
using Slopper.Domain;
namespace Slopper.Api;
@@ -22,11 +25,13 @@ public static class ApiEndpoints
var clips = api.MapGroup("/clips");
clips.MapGet("/", GetClips).WithDisplayName("Get Latest Clips");
clips
.MapGet("/{id}/stream", GetClipStream)
.WithDisplayName("Get Video Stream for Clip")
.Produces(StatusCodes.Status200OK, contentType: "video/mp4")
.Produces(StatusCodes.Status404NotFound);
clips.MapGet("/{id}/stream", GetClipStream).WithDisplayName("Get Video Stream for Clip");
var jobs = api.MapGroup("/jobs");
jobs.MapGet("/", GetJobStatus).WithDisplayName("Get Clip Job Status");
jobs.MapPut("/", StartJob)
.WithDisplayName("Create New Clip")
.WithSummary("Triggers a job generating a new clip.");
return api;
}
@@ -39,7 +44,7 @@ public static class ApiEndpoints
[FromQuery, Range(0, 64)] int limit = 10
) => clipRepository.GetLatest(after, limit, cancellationToken).Select(Clip.FromDomain);
private static async Task<IResult> GetClipStream(
private static async Task<Results<PhysicalFileHttpResult, NotFound>> GetClipStream(
[FromServices] IClipRepository clipRepository,
[FromRoute] Guid id,
CancellationToken cancellationToken
@@ -48,9 +53,9 @@ public static class ApiEndpoints
var clip = await clipRepository.Get(id, cancellationToken);
if (clip is null)
{
return Results.NotFound();
return TypedResults.NotFound();
}
return Results.File(
return TypedResults.PhysicalFile(
clip.Path,
contentType: "video/mp4",
fileDownloadName: $"{clip.Id}.mp4",
@@ -58,4 +63,60 @@ public static class ApiEndpoints
enableRangeProcessing: true
);
}
private static async Task<Ok<JobStatus>> GetJobStatus(
[FromServices] ISchedulerFactory schedulerFactory,
CancellationToken cancellationToken
)
{
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
if (await IsClipGenerationJobRunning(scheduler, cancellationToken))
{
return TypedResults.Ok(new JobStatus(true));
}
var triggers = await scheduler.GetTriggersOfJob(ClipGenerationJob.Key, cancellationToken);
var scheduledTimes = triggers
.Select(t => t.GetNextFireTimeUtc())
.Where(n => n is not null)
.OfType<DateTimeOffset>()
.ToArray();
return TypedResults.Ok(new JobStatus(false, scheduledTimes is [] ? null : scheduledTimes.Min()));
}
private static async Task<Accepted> StartJob(
[FromServices] ISchedulerFactory schedulerFactory,
CancellationToken cancellationToken
)
{
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
if (await IsClipGenerationJobRunning(scheduler, cancellationToken))
{
return TypedResults.Accepted("/api/jobs");
}
var triggers = await scheduler.GetTriggersOfJob(ClipGenerationJob.Key, cancellationToken);
await scheduler.UnscheduleJobs([.. triggers.Select(t => t.Key)], cancellationToken);
await scheduler.TriggerJob(ClipGenerationJob.Key, cancellationToken);
return TypedResults.Accepted("/api/jobs");
}
private static async Task<bool> IsClipGenerationJobRunning(
IScheduler scheduler,
CancellationToken cancellationToken
)
{
var currentlyExecutingJobs = await scheduler.GetCurrentlyExecutingJobs(cancellationToken);
return currentlyExecutingJobs.Any(j => j.JobDetail.Key == ClipGenerationJob.Key);
}
}
public sealed record JobStatus(
bool IsRunning,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] DateTimeOffset? NextScheduledRun = null
);
src/Api/ClipGenerationJob.cs
+68
-0
diff --git a/src/Api/ClipGenerationJob.cs b/src/Api/ClipGenerationJob.cs
new file mode 100644
index 0000000..2df4c12
@@ -0,0 +1,68 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Quartz;
using Slopper.Domain;
namespace Slopper.Api;
[DisallowConcurrentExecution]
public sealed class ClipGenerationJob(
ILogger<ClipGenerationJob> logger,
IOptionsMonitor<ClipGenerationJobOptions> options,
ClipGenerator clipGenerator,
TimeProvider timeProvider
) : IJob
{
public static JobKey Key { get; } = new(nameof(ClipGenerationJob));
public async Task Execute(IJobExecutionContext context)
{
logger.LogDebug("Running clip generation job");
_ = await clipGenerator.Generate(context.CancellationToken);
await context.Scheduler.ScheduleJob(
TriggerBuilder
.Create()
.ForJob(Key)
.StartAt(timeProvider.GetUtcNow() + options.CurrentValue.Interval)
.Build(),
context.CancellationToken
);
}
}
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());
}
extension(IServiceCollection services)
{
public IServiceCollection AddClipGenerationJobOptions()
{
services.AddOptions<ClipGenerationJobOptions>().BindConfiguration("ClipGenerationJob").ValidateOnStart();
services.AddTransient<IValidateOptions<ClipGenerationJobOptions>, ClipGenerationJobOptionsValidator>();
return services;
}
}
}
public sealed class ClipGenerationJobOptions
{
[Required]
public required TimeSpan Interval { get; set; }
}
[OptionsValidator]
public sealed partial class ClipGenerationJobOptionsValidator : IValidateOptions<ClipGenerationJobOptions>;
src/Api/OpenTelemetryExtensions.cs
+4
-2
diff --git a/src/Api/OpenTelemetryExtensions.cs b/src/Api/OpenTelemetryExtensions.cs
index ab043c8..f5e2ad8 100644
@@ -25,7 +25,7 @@ public static class OpenTelemetryExtensions
.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddHttpClientInstrumentation().AddRuntimeInstrumentation();
metrics.AddHttpClientInstrumentation().AddRuntimeInstrumentation().AddAspNetCoreInstrumentation();
})
.WithTracing(tracing =>
{
@@ -33,7 +33,9 @@ public static class OpenTelemetryExtensions
.AddSource(builder.Environment.ApplicationName)
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddFfmpegInstrumentation();
.AddAspNetCoreInstrumentation()
.AddFfmpegInstrumentation()
.AddQuartzInstrumentation();
});
builder.AddOpenTelemetryExporters();
src/Api/Program.cs
+7
-0
diff --git a/src/Api/Program.cs b/src/Api/Program.cs
index f48c423..70302f0 100644
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Quartz;
using Quartz.AspNetCore;
using Slopper.Api;
using Slopper.Domain;
using Slopper.Infrastructure.Ai;
@@ -16,6 +18,11 @@ builder.Services.AddClipSelector().AddClipGenerator();
builder.Services.AddJellyfinDatabase().AddSlopperDatabase().AddFfmpegServices().AddAi();
builder
.Services.AddClipGenerationJobOptions()
.AddQuartz(quartz => quartz.AddClipGenerationJob().AddClipGenerationJobTrigger())
.AddQuartzServer();
using var app = builder.Build();
app.MapOpenApi();
src/Api/appsettings.Development.json
+3
-0
diff --git a/src/Api/appsettings.Development.json b/src/Api/appsettings.Development.json
index dd84c47..876ce4d 100644
@@ -6,6 +6,9 @@
"Slopper": "Debug"
}
},
"ClipGenerationJob": {
"Interval": "00:00:10.000"
},
"ClipSelector": {
"ClippableQuotes": [
"I'll be back",
src/Api/packages.lock.json
+40
-0
diff --git a/src/Api/packages.lock.json b/src/Api/packages.lock.json
index 7124b79..66c4ab6 100644
@@ -63,6 +63,15 @@
"OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Quartz": {
"type": "Direct",
"requested": "[1.15.1-beta.1, )",
"resolved": "1.15.1-beta.1",
"contentHash": "bk6UVaeRXMj9yXewdm28ua9M2C6MTzRGCGZYcJd0CTNwbIdCB/c7Pu02F2y4iQ0NdvZhov+0GO60Vx9IWXpo7A==",
"dependencies": {
"OpenTelemetry.Api": "[1.15.3, 2.0.0)"
}
},
"OpenTelemetry.Instrumentation.Runtime": {
"type": "Direct",
"requested": "[1.15.1, )",
@@ -72,6 +81,21 @@
"OpenTelemetry.Api": "[1.15.3, 2.0.0)"
}
},
"Quartz": {
"type": "Direct",
"requested": "[3.18.1, )",
"resolved": "3.18.1",
"contentHash": "q/n/gKLcApDlTk90BRNvj6P8HKR+vbtZEQxo5BAbeEoN5e/8/aqRuG6yH7tVg02CeAXAD40boS74s9hVnh4XDw=="
},
"Quartz.AspNetCore": {
"type": "Direct",
"requested": "[3.18.1, )",
"resolved": "3.18.1",
"contentHash": "ov6wR05hvxFeusryqnmBTZJEnEWC8pHFKC55td7C6F5Y2MrkdlSg9IR4GVkFGeZFmnjtTL3LlsHCAM9cYwMk6A==",
"dependencies": {
"Quartz.Extensions.Hosting": "3.18.1"
}
},
"Instances": {
"type": "Transitive",
"resolved": "3.0.2",
@@ -369,6 +393,22 @@
"resolved": "8.6.5",
"contentHash": "t+sUVrIwvo7UmsgHGgOG9F0GDZSRIm47u2ylH17Gvcv1q5hNEwgD5GoBlFyc0kh/pebmPyrAgvGsR/65ZBaXlg=="
},
"Quartz.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "3.18.1",
"contentHash": "ct+sIqr7n961jvo695GnqHjB+dhK6b/Y9LSXxK6n9EJ6JzMXpwcusd5u2lCqVqqNHaiGBLXp37E75hpPXHQkzA==",
"dependencies": {
"Quartz": "3.18.1"
}
},
"Quartz.Extensions.Hosting": {
"type": "Transitive",
"resolved": "3.18.1",
"contentHash": "kZ7ufndI5vygLpI0kCeukVKlzpRFtGjfoU3BoWZ6u6QKoRF2Z3KV1dSpv+VpMqRekz3SrObfN0iLZKWldoZLZw==",
"dependencies": {
"Quartz.Extensions.DependencyInjection": "3.18.1"
}
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.11",