📄
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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
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);