Commit: 0a4de0b
Parent: bb30b37

Upload to YouTube in a job

Mårten Åsberg committed on 2026-05-14 at 16:37
src/Api/ApiEndpoints.cs +0 -6
diff --git a/src/Api/ApiEndpoints.cs b/src/Api/ApiEndpoints.cs
index a3dc4e6..1cce2b9 100644
@@ -2,7 +2,6 @@ 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;
@@ -123,8 +122,3 @@ public sealed record Clip(Guid Id, TimeSpan Duration, DateTimeOffset CreatedAt,
public static Clip FromDomain(Domain.Clip clip) =>
new(clip.Id, clip.Duration, clip.CreatedAt, clip.Caption, [.. clip.Tags.Select(t => t.Value)]);
}
public sealed record JobStatus(
bool IsRunning,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] DateTimeOffset? NextScheduledRun = null
);
src/Api/JobStatus.cs +9 -0
diff --git a/src/Api/JobStatus.cs b/src/Api/JobStatus.cs
new file mode 100644
index 0000000..a5ab8c5
@@ -0,0 +1,9 @@
using System;
using System.Text.Json.Serialization;
namespace Slopper.Api;
public sealed record JobStatus(
bool IsRunning,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] DateTimeOffset? NextScheduledRun = null
);
src/Api/Program.cs +1 -1
diff --git a/src/Api/Program.cs b/src/Api/Program.cs
index 9395cc2..46bb403 100644
@@ -36,7 +36,7 @@ builder.Services.AddJellyfinDatabase().AddSlopperDatabase().AddFfmpegServices().
builder
.Services.AddClipGenerationJobOptions()
.AddQuartz() //quartz => quartz.AddClipGenerationJob().AddCleanupJob())
.AddQuartz(quartz => quartz.AddUploadJob()) //quartz.AddClipGenerationJob().AddCleanupJob())
.AddQuartzServer();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie().AddYouTube();
src/Api/UploadJob.cs +30 -0
diff --git a/src/Api/UploadJob.cs b/src/Api/UploadJob.cs
new file mode 100644
index 0000000..d6498da
@@ -0,0 +1,30 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Quartz;
using Slopper.Domain;
namespace Slopper.Api;
public sealed class UploadJob(ILogger<UploadJob> logger, Uploader uploader) : IJob
{
public static JobKey Key { get; } = new(nameof(UploadJob));
public async Task Execute(IJobExecutionContext context)
{
var platform =
context.MergedJobDataMap.GetString("platform")
?? throw new Exception("No platform defined for upload job.");
logger.LogDebug("Running upload job for platform {Platform}", platform);
await uploader.Upload(platform, context.CancellationToken);
}
}
public static class UploadJobExtensions
{
extension(IServiceCollectionQuartzConfigurator quartz)
{
public IServiceCollectionQuartzConfigurator AddUploadJob() =>
quartz.AddJob<UploadJob>(UploadJob.Key, options => options.StoreDurably());
}
}
src/Api/YouTubeApiEndpoints.cs +47 -6
diff --git a/src/Api/YouTubeApiEndpoints.cs b/src/Api/YouTubeApiEndpoints.cs
index 5fa5059..843cc11 100644
@@ -1,13 +1,14 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Google.Apis.Auth.AspNetCore3;
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.Api.YouTubeAuth;
using Slopper.Domain;
namespace Slopper.Api;
@@ -21,7 +22,8 @@ public static class YouTubeApiEndpoints
youtube.MapGet("/login", Login).WithDisplayName("YouTube Login");
youtube.MapPost("/upload", UploadAll).WithDisplayName("Upload Clips to YouTube");
youtube.MapGet("/upload", GetUploadJobStatus).WithDisplayName("Get YouTube upload job status.");
youtube.MapPut("/upload", StartUploadJob).WithDisplayName("Triggers a job uploading clips to YouTube.");
return youtube;
}
@@ -29,8 +31,47 @@ public static class YouTubeApiEndpoints
private static RedirectHttpResult Login() => TypedResults.Redirect("/admin/youtube");
private static IAsyncEnumerable<Upload> UploadAll(
[FromServices] Uploader uploader,
private static async Task<Ok<JobStatus>> GetUploadJobStatus(
[FromServices] ISchedulerFactory schedulerFactory,
CancellationToken cancellationToken
) => uploader.Upload("YouTube", cancellationToken).Select(Upload.FromDomain);
)
{
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
if (await IsUploadJobRunning(scheduler, cancellationToken))
{
return TypedResults.Ok(new JobStatus(true));
}
return TypedResults.Ok(new JobStatus(false));
}
private static async Task<Accepted> StartUploadJob(
[FromServices] YouTubeCredentialsProvider credentialsProvider,
[FromServices] IGoogleAuthProvider googleAuthProvider,
[FromServices] ISchedulerFactory schedulerFactory,
CancellationToken cancellationToken
)
{
var scheduler = await schedulerFactory.GetScheduler(cancellationToken);
if (await IsUploadJobRunning(scheduler, cancellationToken))
{
return TypedResults.Accepted("/api/youtube/upload");
}
credentialsProvider.Credential = await googleAuthProvider.GetCredentialAsync(
cancellationToken: cancellationToken
);
await scheduler.TriggerJob(UploadJob.Key, new JobDataMap { ["platform"] = "YouTube" }, cancellationToken);
return TypedResults.Accepted("/api/youtube/upload");
}
private static async Task<bool> IsUploadJobRunning(IScheduler scheduler, CancellationToken cancellationToken)
{
var currentlyExecutingJobs = await scheduler.GetCurrentlyExecutingJobs(cancellationToken);
return currentlyExecutingJobs.Any(j =>
j.JobDetail.Key == UploadJob.Key && j.MergedJobDataMap.GetString("platform") is "YouTube"
);
}
}
src/Api/YouTubeAuth/YouTubeAuthExtensions.cs +4 -1
diff --git a/src/Api/YouTubeAuth/YouTubeAuthExtensions.cs b/src/Api/YouTubeAuth/YouTubeAuthExtensions.cs
index 910ef12..7605fad 100644
@@ -30,7 +30,10 @@ public static class YouTubeAuthExtensions
auth.Services.AddTransient<IAuthorizationHandler, YouTubeScopeAuthorizationHandler>();
auth.Services.AddTransient<IYouTubeCredentialsProvider, YouTubeCredentialsProvider>();
auth.Services.AddSingleton<YouTubeCredentialsProvider>();
auth.Services.AddSingleton<IYouTubeCredentialsProvider>(sp =>
sp.GetRequiredService<YouTubeCredentialsProvider>()
);
return auth;
}
src/Api/YouTubeAuth/YouTubeCredentialsProvider.cs +6 -4
diff --git a/src/Api/YouTubeAuth/YouTubeCredentialsProvider.cs b/src/Api/YouTubeAuth/YouTubeCredentialsProvider.cs
index 65fd66e..fbe461d 100644
@@ -1,13 +1,15 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Google.Apis.Auth.AspNetCore3;
using Google.Apis.Auth.OAuth2;
using Slopper.Infrastructure.YouTube;
namespace Slopper.Api.YouTubeAuth;
internal sealed class YouTubeCredentialsProvider(IGoogleAuthProvider googleAuthProvider) : IYouTubeCredentialsProvider
internal sealed class YouTubeCredentialsProvider : IYouTubeCredentialsProvider
{
public async Task<ICredential> GetCredentials(CancellationToken cancellationToken) =>
await googleAuthProvider.GetCredentialAsync(cancellationToken: cancellationToken);
public ICredential? Credential { get; set; }
public Task<ICredential> GetCredentials(CancellationToken cancellationToken) =>
Task.FromResult(Credential ?? throw new Exception("YouTube credentials are not available"));
}
src/Domain/Uploader.cs +2 -8
diff --git a/src/Domain/Uploader.cs b/src/Domain/Uploader.cs
index aad45b6..9be80b2 100644
@@ -1,17 +1,13 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
namespace Slopper.Domain;
public sealed class Uploader(IServiceProvider serviceProvider, IClipRepository clipRepository)
{
public async IAsyncEnumerable<Upload> Upload(
string platform,
[EnumeratorCancellation] CancellationToken cancellationToken
)
public async Task Upload(string platform, CancellationToken cancellationToken)
{
var uploader = serviceProvider.GetRequiredKeyedService<IUploader>(platform);
@@ -20,8 +16,6 @@ public sealed class Uploader(IServiceProvider serviceProvider, IClipRepository c
var upload = await uploader.Upload(clip, cancellationToken);
clip.Uploads.Add(upload);
await clipRepository.Save(clip, cancellationToken);
yield return upload;
}
}
}