📄 src/Api/ApiEndpoints.cs
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
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;

public static class ApiEndpoints
{
    extension(IEndpointRouteBuilder endpoints)
    {
        public IEndpointConventionBuilder MapApi()
        {
            var api = endpoints.MapGroup("/api");

            var clips = api.MapGroup("/clips");
            clips.MapGet("/", GetClips).WithDisplayName("Get Latest Clips");
            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.");

            api.MapYouTubeApi();

            return api;
        }
    }

    private static IAsyncEnumerable<Clip> GetClips(
        [FromServices] ClipDetailsService clipDetailsService,
        CancellationToken cancellationToken,
        [FromQuery] Guid? after = null,
        [FromQuery, Range(0, 64)] int limit = 10
    ) => clipDetailsService.GetLatest(after, limit, cancellationToken).Select(Clip.FromDomain);

    private static async Task<Results<PhysicalFileHttpResult, NotFound>> GetClipStream(
        [FromServices] IClipRepository clipRepository,
        [FromRoute] Guid id,
        CancellationToken cancellationToken
    )
    {
        var clip = await clipRepository.Get(id, cancellationToken);
        if (clip is null)
        {
            return TypedResults.NotFound();
        }
        return TypedResults.PhysicalFile(
            clip.Path,
            contentType: "video/mp4",
            fileDownloadName: $"{clip.Id}.mp4",
            lastModified: clip.CreatedAt,
            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 Clip(
    Guid Id,
    TimeSpan Duration,
    DateTimeOffset CreatedAt,
    string? Caption,
    string[] Tags,
    ClipUpload[] Uploads,
    ClipMediaInfo MediaInfo
)
{
    public static Clip FromDomain(Domain.ClipDetails details) =>
        new(
            details.Clip.Id,
            details.Clip.Duration,
            details.Clip.CreatedAt,
            details.Clip.Caption,
            [.. details.Clip.Tags.Select(t => t.Value)],
            [.. details.Clip.Uploads.Select(u => new ClipUpload(u.CanonicalUrl.ToString(), u.Platform, u.PublishedAt))],
            new ClipMediaInfo(
                details.MediaInfo?.Name,
                details.MediaInfo?.SeriesName,
                details.MediaInfo?.OriginalReleaseDate
            )
        );
}

public sealed record ClipUpload(string CanonicalUrl, string Platform, DateTimeOffset PublishedAt);

public sealed record ClipMediaInfo(string? Name, string? SeriesName, DateOnly? OriginalReleaseDate);