📄
src/Api/ApiEndpoints.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
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] IClipRepository clipRepository, CancellationToken cancellationToken, [FromQuery] Guid? after = null, [FromQuery, Range(0, 64)] int limit = 10 ) => clipRepository.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) { public static Clip FromDomain(Domain.Clip clip) => new(clip.Id, clip.Duration, clip.CreatedAt, clip.Caption, [.. clip.Tags.Select(t => t.Value)]); }