feat: determine if notification is for stream or not
This commit is contained in:
@@ -1,18 +1,50 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.Configure<HostOptions>(
|
||||
static options => options.ShutdownTimeout = TimeSpan.FromSeconds(30)
|
||||
);
|
||||
|
||||
builder.Services
|
||||
.AddOptionsWithValidateOnStart<SubscriptionOptions>()
|
||||
.BindConfiguration(nameof(SubscriptionOptions))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
builder.Services
|
||||
.AddOptionsWithValidateOnStart<YouTubeClientOptions>()
|
||||
.BindConfiguration(nameof(YouTubeClientOptions))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
builder.Services
|
||||
.AddOptionsWithValidateOnStart<PubSubClientOptions>()
|
||||
.BindConfiguration(nameof(PubSubClientOptions))
|
||||
.ValidateDataAnnotations();
|
||||
|
||||
builder.Services
|
||||
.AddHttpClient<IPubSubClient, PubSubClient>(
|
||||
static c => c.BaseAddress = new("https://pubsubhubbub.appspot.com")
|
||||
static (sp, c) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<PubSubClientOptions>>().Value;
|
||||
c.BaseAddress = new(options.BaseUrl);
|
||||
}
|
||||
)
|
||||
.AddStandardResilienceHandler();
|
||||
|
||||
builder.Services
|
||||
.AddHttpClient<IYouTubeDataApiClient, YouTubeDataApiClient>(
|
||||
static (sp, c) =>
|
||||
{
|
||||
var options = sp.GetRequiredService<IOptions<YouTubeClientOptions>>().Value;
|
||||
c.BaseAddress = new(options.BaseUrl);
|
||||
}
|
||||
)
|
||||
.AddStandardResilienceHandler();
|
||||
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ConcurrentQueue<SubscribeTask>>();
|
||||
builder.Services.AddHostedService<SubscriptionWorker>();
|
||||
|
||||
var app = builder.Build();
|
||||
@@ -22,17 +54,52 @@ if (app.Environment.IsDevelopment())
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
// app.UseHttpsRedirection();
|
||||
|
||||
const string ytCallback = "yt-callback";
|
||||
|
||||
app.MapGet(ytCallback, VerifySubscriptionHandler.HandleAsync);
|
||||
|
||||
// TODO: Implement logic to do the following:
|
||||
// - extract video id from notification
|
||||
// - identify video as a stream or not
|
||||
// - if is stream create discord message
|
||||
// - if not then just log a message
|
||||
app.MapPost(ytCallback, static () => "hello");
|
||||
app.MapPost(ytCallback, static async (HttpContext context, [FromServices] ILogger<Program> logger, [FromServices] IYouTubeDataApiClient youTubeDataApiClient) =>
|
||||
{
|
||||
var body = "";
|
||||
using StreamReader stream = new(context.Request.Body);
|
||||
body = await stream.ReadToEndAsync();
|
||||
|
||||
var videoIdRegex = VideoIdRegex();
|
||||
var match = videoIdRegex.Match(body);
|
||||
|
||||
if (match.Success is false)
|
||||
{
|
||||
logger.LogWarning("No video ID found in the request body.");
|
||||
}
|
||||
|
||||
var videoId = match.Groups[1].Value;
|
||||
var video = await youTubeDataApiClient.GetVideoByIdAsync(videoId, ["liveStreamingDetails"]);
|
||||
|
||||
if (video is null)
|
||||
{
|
||||
logger.LogWarning("Video with ID {VideoId} not found.", videoId);
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (video.IsStream is false)
|
||||
{
|
||||
logger.LogInformation("Video ID {VideoId} is not a live stream.", videoId);
|
||||
return Results.Ok();
|
||||
}
|
||||
|
||||
logger.LogInformation("Video ID {VideoId} is a live stream.", videoId);
|
||||
|
||||
return Results.Ok();
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
internal partial class Program
|
||||
{
|
||||
[GeneratedRegex(@"<yt:videoId>(.*?)</yt:videoId>")]
|
||||
private static partial Regex VideoIdRegex();
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
global using System.Collections.Concurrent;
|
||||
global using System.ComponentModel.DataAnnotations;
|
||||
global using System.Text.Json.Serialization;
|
||||
|
||||
global using Microsoft.AspNetCore.Mvc;
|
||||
global using Microsoft.Extensions.Options;
|
||||
|
||||
global using StevesBot.Webhook;
|
||||
global using StevesBot.Webhook.YouTube;
|
||||
global using StevesBot.Webhook.YouTube.Data;
|
||||
global using StevesBot.Webhook.YouTube.Tasks;
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StevesBot.Webhook.YouTube.Data;
|
||||
|
||||
internal interface IYouTubeDataApiClient
|
||||
{
|
||||
Task<YouTubeVideo?> GetVideoByIdAsync(
|
||||
string videoId,
|
||||
string[]? part = null,
|
||||
CancellationToken cancellationToken = default
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
|
||||
namespace StevesBot.Webhook.YouTube.Data;
|
||||
|
||||
internal sealed class YouTubeDataApiClient(
|
||||
HttpClient httpClient,
|
||||
ILogger<YouTubeDataApiClient> logger,
|
||||
IOptions<YouTubeClientOptions> options
|
||||
) : IYouTubeDataApiClient
|
||||
{
|
||||
private const string VideosEndpoint = "videos";
|
||||
private readonly HttpClient _httpClient = httpClient;
|
||||
private readonly ILogger<YouTubeDataApiClient> _logger = logger;
|
||||
private readonly YouTubeClientOptions _options = options.Value;
|
||||
|
||||
public async Task<YouTubeVideo?> GetVideoByIdAsync(
|
||||
string videoId,
|
||||
string[]? part = null,
|
||||
CancellationToken cancellationToken = default
|
||||
)
|
||||
{
|
||||
var queryParams = new Dictionary<string, string?>
|
||||
{
|
||||
["id"] = videoId,
|
||||
["key"] = _options.ApiKey,
|
||||
};
|
||||
|
||||
if (part is not null && part.Length > 0)
|
||||
{
|
||||
queryParams["part"] = string.Join(',', part);
|
||||
}
|
||||
|
||||
var requestEndpoint = QueryHelpers.AddQueryString(VideosEndpoint, queryParams);
|
||||
var requestUri = new Uri(requestEndpoint, UriKind.Relative);
|
||||
var response = await _httpClient.GetAsync(requestUri, cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode is false)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Failed to get video by ID {VideoId} from YouTube Data API. Status code: {StatusCode}, Content: {Content}",
|
||||
videoId,
|
||||
response.StatusCode,
|
||||
content
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var responseContent = await response.Content.ReadFromJsonAsync<YouTubeVideoListResponse>(cancellationToken);
|
||||
return responseContent?.Items.FirstOrDefault(i => i.Id == videoId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StevesBot.Webhook.YouTube.Data;
|
||||
|
||||
internal sealed record YouTubeLiveStreamingDetails
|
||||
{
|
||||
[JsonPropertyName("actualStartTime")]
|
||||
public DateTimeOffset? ActualStartTime { get; init; }
|
||||
|
||||
[JsonPropertyName("actualEndTime")]
|
||||
public DateTimeOffset? ActualEndTime { get; init; }
|
||||
|
||||
[JsonPropertyName("scheduledStartTime")]
|
||||
public DateTimeOffset? ScheduledStartTime { get; init; }
|
||||
|
||||
[JsonPropertyName("scheduledEndTime")]
|
||||
public DateTimeOffset? ScheduledEndTime { get; init; }
|
||||
|
||||
[JsonPropertyName("concurrentViewers")]
|
||||
public ulong? ConcurrentViewers { get; init; }
|
||||
|
||||
[JsonPropertyName("activeLiveChatId")]
|
||||
public string? ActiveLiveChatId { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StevesBot.Webhook.YouTube.Data;
|
||||
|
||||
internal sealed record YouTubePageInfo
|
||||
{
|
||||
[JsonPropertyName("totalResults")]
|
||||
public int TotalResults { get; init; }
|
||||
|
||||
[JsonPropertyName("resultsPerPage")]
|
||||
public int ResultsPerPage { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace StevesBot.Webhook.YouTube.Data;
|
||||
|
||||
internal sealed record YouTubeVideo
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("liveStreamingDetails")]
|
||||
public YouTubeLiveStreamingDetails? LiveStreamingDetails { get; init; }
|
||||
|
||||
public bool IsStream => LiveStreamingDetails is not null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StevesBot.Webhook.YouTube.Data;
|
||||
|
||||
internal sealed record YouTubeVideoListResponse
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public YouTubeVideo[] Items { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("pageInfo")]
|
||||
public YouTubePageInfo PageInfo { get; init; } = new YouTubePageInfo();
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace StevesBot.Webhook.YouTube;
|
||||
|
||||
internal sealed class PubSubClientOptions
|
||||
{
|
||||
public string BaseUrl { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -3,15 +3,29 @@ namespace StevesBot.Webhook.YouTube;
|
||||
internal sealed class SubscriptionWorker(
|
||||
ILogger<SubscriptionWorker> logger,
|
||||
IOptions<SubscriptionOptions> options,
|
||||
IPubSubClient pubSubClient
|
||||
) : IHostedLifecycleService
|
||||
IPubSubClient pubSubClient,
|
||||
ConcurrentQueue<SubscribeTask> subscriptionQueue,
|
||||
TimeProvider timeProvider
|
||||
) : IHostedLifecycleService, IDisposable
|
||||
{
|
||||
private readonly ILogger<SubscriptionWorker> _logger = logger;
|
||||
private readonly SubscriptionOptions _options = options.Value;
|
||||
private readonly IPubSubClient _pubSubClient = pubSubClient;
|
||||
private readonly ConcurrentQueue<SubscribeTask> _subscriptionQueue = subscriptionQueue;
|
||||
private readonly TimeProvider _timeProvider = timeProvider;
|
||||
private ITimer? _subscriptionTimer;
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting subscription worker");
|
||||
|
||||
_subscriptionTimer = _timeProvider.CreateTimer(
|
||||
callback: async _ => await ProcessSubscriptionQueueAsync(cancellationToken),
|
||||
state: null,
|
||||
dueTime: TimeSpan.FromSeconds(0),
|
||||
period: TimeSpan.FromDays(1)
|
||||
);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -42,6 +56,7 @@ internal sealed class SubscriptionWorker(
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_subscriptionTimer?.Change(Timeout.InfiniteTimeSpan, TimeSpan.Zero);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -54,4 +69,52 @@ internal sealed class SubscriptionWorker(
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task ProcessSubscriptionQueueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (_subscriptionQueue.TryDequeue(out var task))
|
||||
{
|
||||
// We will attempt to process the task
|
||||
// once every day so we will aim to
|
||||
// process it two days before it expires.
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var dueAt = task.ExpiresAt - TimeSpan.FromDays(2);
|
||||
var isDue = now >= dueAt;
|
||||
|
||||
if (isDue is false)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Skipping subscription task for topic {TopicUrl} as it is not due yet. Due at: {DueAt}, Current time: {CurrentTime}",
|
||||
task.TopicUrl,
|
||||
dueAt,
|
||||
now
|
||||
);
|
||||
|
||||
_subscriptionQueue.Enqueue(task);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Processing subscription task for topic: {TopicUrl}", task.TopicUrl);
|
||||
|
||||
var isSubscribed = await _pubSubClient.SubscribeAsync(
|
||||
task.CallbackUrl,
|
||||
task.TopicUrl,
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
if (isSubscribed)
|
||||
{
|
||||
_logger.LogInformation("Successfully subscribed to topic: {TopicUrl}", task.TopicUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Failed to subscribe to topic: {TopicUrl}", task.TopicUrl);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_subscriptionTimer?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
namespace StevesBot.Webhook.YouTube.Tasks;
|
||||
|
||||
internal sealed record SubscribeTask : SubscriptionTask
|
||||
internal sealed record SubscribeTask
|
||||
{
|
||||
public string CallbackUrl { get; init; } = string.Empty;
|
||||
public string TopicUrl { get; init; } = string.Empty;
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace StevesBot.Webhook.YouTube.Tasks;
|
||||
|
||||
internal abstract record SubscriptionTask
|
||||
{
|
||||
public string CallbackUrl { get; init; } = string.Empty;
|
||||
public string TopicUrl { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace StevesBot.Webhook.YouTube.Tasks;
|
||||
|
||||
internal sealed record UnsubscribeTask : SubscriptionTask
|
||||
{
|
||||
}
|
||||
@@ -9,7 +9,8 @@ internal static class VerifySubscriptionHandler
|
||||
[FromQuery(Name = "hub.challenge")] string? challenge,
|
||||
[FromQuery(Name = "hub.lease_seconds")] string? leaseSeconds,
|
||||
[FromServices] IOptions<SubscriptionOptions> subOptions,
|
||||
[FromServices] ILogger<Program> logger
|
||||
[FromServices] ILogger<Program> logger,
|
||||
[FromServices] ConcurrentQueue<SubscribeTask> subscriptionQueue
|
||||
)
|
||||
{
|
||||
if (mode is "denied")
|
||||
@@ -26,23 +27,31 @@ internal static class VerifySubscriptionHandler
|
||||
|
||||
if (mode is "subscribe")
|
||||
{
|
||||
// TODO: If it is a subscription request
|
||||
// we need to queue up a resubscription
|
||||
// request to be executed just before
|
||||
// the hub.lease expires.
|
||||
logger.LogInformation(
|
||||
"Received subscription request for topic: {Topic}, challenge: {Challenge}, lease: {LeaseSeconds}",
|
||||
topic,
|
||||
challenge,
|
||||
leaseSeconds
|
||||
);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(leaseSeconds) || !long.TryParse(leaseSeconds, out var parsedSeconds))
|
||||
{
|
||||
logger.LogWarning("Invalid or missing lease_seconds parameter: {LeaseSeconds}", leaseSeconds);
|
||||
return Results.BadRequest("Invalid lease_seconds parameter");
|
||||
}
|
||||
|
||||
var task = new SubscribeTask
|
||||
{
|
||||
CallbackUrl = subOptions.Value.CallbackUrl,
|
||||
TopicUrl = topic,
|
||||
ExpiresAt = DateTime.UtcNow.AddSeconds(parsedSeconds),
|
||||
};
|
||||
|
||||
subscriptionQueue.Enqueue(task);
|
||||
}
|
||||
|
||||
if (mode is "unsubscribe")
|
||||
{
|
||||
// TODO: If it is a unsubscription request
|
||||
// we need to queue up an ubsubscription
|
||||
// request to be executed immediately
|
||||
logger.LogInformation(
|
||||
"Received unsubscription request for topic: {Topic}, challenge: {Challenge}",
|
||||
topic,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace StevesBot.Webhook.YouTube;
|
||||
|
||||
internal sealed record YouTubeClientOptions
|
||||
{
|
||||
[Required]
|
||||
public string BaseUrl { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
public string ApiKey { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"YouTubeClientOptions": {
|
||||
"BaseUrl": "BaseUrl",
|
||||
"ApiKey": "ApiKey"
|
||||
},
|
||||
"SubscriptionOptions": {
|
||||
"CallbackUrl": "CallbackUrl",
|
||||
"TopicUrl": "TopicUrl"
|
||||
},
|
||||
"PubSubClientOptions": {
|
||||
"BaseUrl": "BaseUrl"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user