Skip to content

Commit

Permalink
Fixed caching (response caching and output caching are redundant) and…
Browse files Browse the repository at this point in the history
… increased duration from 1 to 3 minutes. Exclude runs in which annoying people are participating, can be disabled with optional query parameter ?includeAnnoyingPeople=true. Use tracker.gamesdonequick.com hostname instead of gamesdonequick.com for presumably less load-balancer proxying.
  • Loading branch information
Aldaviva committed Jun 3, 2024
1 parent a955d5e commit af71b33
Show file tree
Hide file tree
Showing 8 changed files with 127 additions and 76 deletions.
21 changes: 11 additions & 10 deletions GamesDoneQuickCalendarFactory/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
using Ical.Net;
using Ical.Net.Serialization;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Net.Http.Headers;
using NodaTime;
using System.Text;
using System.Text.RegularExpressions;

const string ICALENDAR_MIME_TYPE = "text/calendar;charset=UTF-8";
const int CACHE_DURATION_MINUTES = 1;
const string ICALENDAR_MIME_TYPE = "text/calendar;charset=UTF-8";
const int CACHE_DURATION_MINUTES = 3;
const string QUERY_PARAM_CACHE_POLICY = "Vary by query param";

BomSquad.DefuseUtf8Bom();

Expand All @@ -22,8 +24,7 @@
.UseSystemd();

builder.Services
.AddOutputCache()
.AddResponseCaching()
.AddOutputCache(options => options.AddPolicy(QUERY_PARAM_CACHE_POLICY, policyBuilder => policyBuilder.SetVaryByQuery("includeAnnoyingPeople")))
.AddHttpClient()
.AddSingleton<ICalendarGenerator, CalendarGenerator>()
.AddSingleton<IEventDownloader, EventDownloader>()
Expand All @@ -34,18 +35,18 @@
webApp
.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto })
.UseOutputCache()
.UseResponseCaching()
.Use(async (context, next) => {
context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue { Public = true, MaxAge = TimeSpan.FromMinutes(CACHE_DURATION_MINUTES) };
context.Response.Headers[HeaderNames.Vary] = new[] { HeaderNames.AcceptEncoding };
await next();
});

webApp.MapGet("/", [OutputCache(Duration = CACHE_DURATION_MINUTES * 60)] async (ICalendarGenerator calendarGenerator, HttpResponse response) => {
Calendar calendar = await calendarGenerator.generateCalendar();
response.ContentType = ICALENDAR_MIME_TYPE;
await new CalendarSerializer().serializeAsync(calendar, response.Body, Encoding.UTF8);
});
webApp.MapGet("/", [OutputCache(Duration = CACHE_DURATION_MINUTES * 60, PolicyName = QUERY_PARAM_CACHE_POLICY)]
async Task (ICalendarGenerator calendarGenerator, HttpResponse response, [FromQuery] bool includeAnnoyingPeople = false) => {
Calendar calendar = await calendarGenerator.generateCalendar(includeAnnoyingPeople);
response.ContentType = ICALENDAR_MIME_TYPE;
await new CalendarSerializer().serializeAsync(calendar, response.Body, Encoding.UTF8);
});

webApp.MapGet("/badge.json", [OutputCache(Duration = CACHE_DURATION_MINUTES * 60)] async (IEventDownloader eventDownloader) =>
await eventDownloader.downloadSchedule() is { } schedule
Expand Down
12 changes: 10 additions & 2 deletions GamesDoneQuickCalendarFactory/Services/CalendarGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,32 @@
using Ical.Net;
using Ical.Net.CalendarComponents;
using Ical.Net.DataTypes;
using System.Collections.Frozen;

namespace GamesDoneQuickCalendarFactory.Services;

public interface ICalendarGenerator {

Task<Calendar> generateCalendar();
Task<Calendar> generateCalendar(bool includeAnnoyingPeople = false);

}

public sealed class CalendarGenerator(IEventDownloader eventDownloader, ILogger<CalendarGenerator> logger): ICalendarGenerator {

private static readonly Uri TWITCH_STREAM_URL = new("https://www.twitch.tv/gamesdonequick");

public async Task<Calendar> generateCalendar() {
private static readonly IReadOnlySet<int> ANNOYING_PERSON_BLACKLIST = new HashSet<int> {
60 // Spike Vegeta
}.ToFrozenSet();

public async Task<Calendar> generateCalendar(bool includeAnnoyingPeople = false) {
logger.LogDebug("Downloading schedule from Games Done Quick website");
Event? gdqEvent = await eventDownloader.downloadSchedule();
Calendar calendar = new() { Method = CalendarMethods.Publish };

if (gdqEvent != null) {
calendar.Events.AddRange(gdqEvent.runs
.Where(run => includeAnnoyingPeople || !containsAnnoyingPerson(run))
.Select((run, runIndex) => new CalendarEvent {
Uid = $"aldaviva.com/{gdqEvent.shortTitle}/{run.name}",
// UTC works better than trying to coerce the OffsetDateTime into a ZonedDateTime, because NodaTime will pick a zone like UTC-5 instead of America/New_York (which makes sense), but Vivaldi doesn't apply zones like UTC-5 correctly and render the times as if they were local time, leading to events starting 3 hours too early for subscribers in America/Los_Angeles. Alternatively, we could map offsets and dates to more well-known zones like America/New_York, or use the zone specified in the GdqEvent.timezone property except I don't know if Vivaldi handles US/Eastern
Expand Down Expand Up @@ -58,4 +64,6 @@ public async Task<Calendar> generateCalendar() {

private static string getName(Person person) => person.name;

private static bool containsAnnoyingPerson(GameRun run) => run.runners.Concat(run.commentators).Concat(run.hosts).IntersectBy(ANNOYING_PERSON_BLACKLIST, person => person.id).Any();

}
14 changes: 11 additions & 3 deletions GamesDoneQuickCalendarFactory/Services/EventDownloader.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using GamesDoneQuickCalendarFactory.Data;
using GamesDoneQuickCalendarFactory.Data.GDQ;
using NodaTime;
using System.Collections.Frozen;

namespace GamesDoneQuickCalendarFactory.Services;

Expand All @@ -12,8 +13,15 @@ public interface IEventDownloader {

public class EventDownloader(IGdqClient gdq, IClock clock): IEventDownloader {

private static readonly Duration MAX_RUN_DURATION = Duration.FromHours(11);
private static readonly IReadOnlySet<int> RUNNER_ID_BLACKLIST = new HashSet<int> { 367, 1434, 1884, 1885, 2071, 6154 };
private static readonly Duration MAX_RUN_DURATION = Duration.FromHours(11);

private static readonly IReadOnlySet<int> RUNNER_BLACKLIST = new HashSet<int> {
367, // Tech Crew
1434, // Interview Crew
1884, // Faith (the Frame Fatales saber-toothed tiger mascot)
1885, // Everyone!
2071 // Frame Fatales Interstitial Team
}.ToFrozenSet();

/// <summary>
/// If there are no calendar events ending in the last 1 day, and no upcoming events, hide all those old past events.
Expand All @@ -24,7 +32,7 @@ public class EventDownloader(IGdqClient gdq, IClock clock): IEventDownloader {
GdqEvent currentEvent = await gdq.getCurrentEvent();

IReadOnlyList<GameRun> runs = (await gdq.getEventRuns(currentEvent))
.Where(run => !run.runners.IntersectBy(RUNNER_ID_BLACKLIST, runner => runner.id).Any() && !isSleep(run))
.Where(run => !run.runners.IntersectBy(RUNNER_BLACKLIST, runner => runner.id).Any() && !isSleep(run))
.ToList().AsReadOnly();

Instant latestRunEndTimeToInclude = clock.GetCurrentInstant() - MAX_EVENT_END_CLEANUP_DELAY;
Expand Down
15 changes: 7 additions & 8 deletions GamesDoneQuickCalendarFactory/Services/GdqClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ public interface IGdqClient {

public class GdqClient(HttpClient httpClient): IGdqClient {

private static readonly Uri BASE_URL = new("https://gamesdonequick.com/");
private static readonly Uri SCHEDULE_URL = BASE_URL.WithPath("schedule");
private static readonly Uri EVENTS_API_LOCATION = BASE_URL.WithPath("tracker/api/v2/events");
private static readonly Uri SCHEDULE_URL = new("https://gamesdonequick.com/schedule");
private static readonly Uri EVENTS_API_URL = new("https://tracker.gamesdonequick.com/tracker/api/v2/events");

internal static readonly JsonSerializerOptions JSON_SERIALIZER_OPTIONS = new() {
Converters = {
Expand All @@ -44,7 +43,7 @@ public async Task<int> getCurrentEventId() {
}

public async Task<GdqEvent> getEvent(int eventId) {
Uri eventUrl = EVENTS_API_LOCATION.WithPath(eventId.ToString());
Uri eventUrl = EVENTS_API_URL.WithPath(eventId.ToString());
return (await httpClient.GetFromJsonAsync<GdqEvent>(eventUrl, JSON_SERIALIZER_OPTIONS))!;
}

Expand All @@ -54,7 +53,7 @@ public async Task<GdqEvent> getEvent(int eventId) {

public async Task<IEnumerable<GameRun>> getEventRuns(int eventId) {
IList<GameRun>? runs = null;
Uri runsUrl = EVENTS_API_LOCATION.WithPath(eventId.ToString()).WithPath("runs");
Uri runsUrl = EVENTS_API_URL.WithPath(eventId.ToString()).WithPath("runs");
var resultsCount = new ValueHolderStruct<int>();

await foreach (GdqRun run in downloadAllPages<GdqRun>(runsUrl, resultsCount)) {
Expand All @@ -75,9 +74,9 @@ public async Task<IEnumerable<GameRun>> getEventRuns(int eventId) {
private static Person getPerson(GdqPerson person) => new(person.id, person.name);

private async IAsyncEnumerable<T> downloadAllPages<T>(Uri firstPageUrl, ValueHolderStruct<int>? resultsCount = default, [EnumeratorCancellation] CancellationToken c = default) {
for (Uri? nextPageToDownload = firstPageUrl; nextPageToDownload != null;) {
JsonObject? page = await httpClient.GetFromJsonAsync<JsonObject>(nextPageToDownload, JSON_SERIALIZER_OPTIONS, c);
nextPageToDownload = page?["next"]?.GetValue<Uri?>();
JsonObject? page;
for (Uri? nextPageToDownload = firstPageUrl; nextPageToDownload != null; nextPageToDownload = page?["next"]?.GetValue<Uri?>()) {
page = await httpClient.GetFromJsonAsync<JsonObject>(nextPageToDownload, JSON_SERIALIZER_OPTIONS, c);

if (page != null) {
if (resultsCount is { value: null }) {
Expand Down
67 changes: 51 additions & 16 deletions Tests/CalendarGeneratorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,43 +19,43 @@ public CalendarGeneratorTest() {

[Fact]
public async Task generateCalendar() {
Event @event = new("Awesome Games Done Quick 2024", "AGDQ2024", new[] {
Event @event = new("Awesome Games Done Quick 2024", "AGDQ2024", [
new GameRun(
OffsetDateTimePattern.GeneralIso.Parse("2024-01-14T12:12:00-05:00").GetValueOrThrow(),
Duration.FromMinutes(36),
"TUNIC",
"Any% Unrestricted — PC",
new[] { new Person(1, "Radicoon") },
new[] { new Person(2, "kevinregamey"), new Person(3, "silentdestroyer") },
new[] { new Person(4, "AttyJoe") }),
[new Person(1, "Radicoon")],
[new Person(2, "kevinregamey"), new Person(3, "silentdestroyer")],
[new Person(4, "AttyJoe")]),

new GameRun(
OffsetDateTimePattern.GeneralIso.Parse("2024-01-14T12:48:00-05:00").GetValueOrThrow(),
Duration.FromMinutes(33),
"Super Monkey Ball",
"Master — Wii",
new[] { new Person(1, "Helix") },
new[] { new Person(2, "limy"), new Person(3, "PeasSMB") },
new[] { new Person(4, "AttyJoe") }),
[new Person(1, "Helix")],
[new Person(2, "limy"), new Person(3, "PeasSMB")],
[new Person(4, "AttyJoe")]),

new GameRun(
OffsetDateTimePattern.GeneralIso.Parse("2024-01-14T13:21:00-05:00").GetValueOrThrow(),
Duration.FromHours(1) + Duration.FromMinutes(13),
"Donkey Kong Country",
"101% — SNES",
new[] { new Person(1, "Tonkotsu") },
new[] { new Person(2, "Glan"), new Person(3, "V0oid") },
new[] { new Person(4, "AttyJoe") }),
[new Person(1, "Tonkotsu")],
[new Person(2, "Glan"), new Person(3, "V0oid")],
[new Person(4, "AttyJoe")]),

new GameRun(
OffsetDateTimePattern.GeneralIso.Parse("2024-01-20T21:04:00-05:00").GetValueOrThrow(),
Duration.FromHours(2) + Duration.FromMinutes(56),
"Final Fantasy V Pixel Remaster",
"Any% Cutscene Remover — PC",
new[] { new Person(1, "Zic3") },
new[] { new Person(2, "FoxyJira"), new Person(3, "WoadyB") },
new[] { new Person(4, "Prolix") })
});
[new Person(1, "Zic3")],
[new Person(2, "FoxyJira"), new Person(3, "WoadyB")],
[new Person(4, "Prolix")])
]);

A.CallTo(() => eventDownloader.downloadSchedule()).Returns(@event);

Expand All @@ -64,8 +64,6 @@ public async Task generateCalendar() {
actual.Events.Should().HaveCount(4);

CalendarEvent actualEvent = actual.Events[0];

actualEvent = actual.Events[0];
actualEvent.Start.Should().Be(OffsetDateTimePattern.GeneralIso.Parse("2024-01-14T12:12:00-05:00").GetValueOrThrow().toIDateTimeUtc());
actualEvent.Duration.Should().Be(TimeSpan.FromMinutes(36));
actualEvent.Summary.Should().Be("TUNIC");
Expand Down Expand Up @@ -121,4 +119,41 @@ public async Task generateCalendar() {
actualEvent.Alarms.Should().BeEmpty();
}

[Fact]
public async Task ignoreAnnoyingPeople() {
Event @event = new("Test", "Test", [
new GameRun(
new OffsetDateTime(),
Duration.FromMinutes(30),
"Annoying runner",
"",
[new Person(60, "spikevegeta")],
[new Person(2, "kevinregamey"), new Person(3, "silentdestroyer")],
[new Person(4, "AttyJoe")]),

new GameRun(
new OffsetDateTime(),
Duration.FromMinutes(30),
"Annoying commentator",
"",
[new Person(1, "Radicoon")],
[new Person(60, "spikevegeta")],
[new Person(4, "AttyJoe")]),

new GameRun(
new OffsetDateTime(),
Duration.FromMinutes(30),
"Annoying host",
"",
[new Person(1, "Radicoon")],
[new Person(2, "kevinregamey"), new Person(3, "silentdestroyer")],
[new Person(60, "spikevegeta")])
]);

A.CallTo(() => eventDownloader.downloadSchedule()).Returns(@event);

(await calendarGenerator.generateCalendar(true)).Events.Should().HaveCount(3);
(await calendarGenerator.generateCalendar()).Events.Should().BeEmpty();
}

}
36 changes: 18 additions & 18 deletions Tests/EventDownloaderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ public async Task downloadSchedule() {
GameRun tunic = new(new LocalDateTime(2024, 1, 14, 12, 12, 0).WithOffset(Offset.FromHours(-5)),
Duration.FromMinutes(36),
"TUNIC", "Any% Unrestricted — PC",
new[] { new Person(1, "Radicoon") },
new[] { new Person(2, "kevinregamey"), new Person(3, "silentdestroyer") },
new[] { new Person(4, "AttyJoe") });
[new Person(1, "Radicoon")],
[new Person(2, "kevinregamey"), new Person(3, "silentdestroyer")],
[new Person(4, "AttyJoe")]);
A.CallTo(() => gdq.getEventRuns(gdqEvent)).Returns([tunic]);

Event? actual = await eventDownloader.downloadSchedule();
Expand All @@ -49,9 +49,9 @@ public async Task downloadScheduleEmpty() {
GameRun tunic = new(new LocalDateTime(2024, 1, 14, 12, 12, 0).WithOffset(Offset.FromHours(-5)),
Duration.FromMinutes(36),
"TUNIC", "Any% Unrestricted — PC",
new[] { new Person(1, "Radicoon") },
new[] { new Person(2, "kevinregamey"), new Person(3, "silentdestroyer") },
new[] { new Person(4, "AttyJoe") });
[new Person(1, "Radicoon")],
[new Person(2, "kevinregamey"), new Person(3, "silentdestroyer")],
[new Person(4, "AttyJoe")]);
A.CallTo(() => gdq.getEventRuns(gdqEvent)).Returns([tunic]);

Event? actual = await eventDownloader.downloadSchedule();
Expand All @@ -73,7 +73,7 @@ public async Task ignoreSleepRuns() {
Duration.FromMinutes(10),
"Real run",
"To show the test works",
new[] { new Person(1, "Runner") },
[new Person(1, "Runner")],
Enumerable.Empty<Person>(),
Enumerable.Empty<Person>()),

Expand All @@ -82,16 +82,16 @@ public async Task ignoreSleepRuns() {
Duration.FromHours(12) + Duration.FromMinutes(53),
"Sleep",
"Pillow Fight Boss Rush — GDQ Studio",
new[] { new Person(1, "Faith") }, // Faith is actually 1884, but that case is tested separately below
[new Person(1, "Faith")], // Faith is actually 1884, but that case is tested separately below
Enumerable.Empty<Person>(),
new[] { new Person(2, "Velocity") }),
[new Person(2, "Velocity")]),

// Long event
new(now,
Duration.FromHours(14) + Duration.FromMinutes(48),
"Day 1 Intermission",
"Intermission — Offline",
new[] { new Person(1, "Twitchcon") },
[new Person(1, "Twitchcon")],
Enumerable.Empty<Person>(),
Enumerable.Empty<Person>()),

Expand All @@ -100,47 +100,47 @@ public async Task ignoreSleepRuns() {
Duration.FromSeconds(15),
"Sleep",
"get-some-rest-too% — GDQ Studio",
new[] { new Person(1, "GDQ Studio") },
[new Person(1, "GDQ Studio")],
Enumerable.Empty<Person>(),
new[] { new Person(2, "Studio Workers") }),
[new Person(2, "Studio Workers")]),

// Tech Crew
new(now, Duration.FromMinutes(70),
"The Checkpoint",
"Day 1 - Sunday — Live",
new[] { new Person(367, "Tech Crew") },
[new Person(367, "Tech Crew")],
Enumerable.Empty<Person>(),
new[] { new Person(205, "TheKingsPride") }),
[new Person(205, "TheKingsPride")]),

// Interview Crew
new(now, Duration.FromMinutes(42),
"AGDQ 2024 Pre-Show",
"Pre-Show — GDQ",
new[] { new Person(1434, "Interview Crew") },
[new Person(1434, "Interview Crew")],
Enumerable.Empty<Person>(),
Enumerable.Empty<Person>()),

// Faith
new(now, Duration.FromMinutes(1), // actually longer, but long events are tested separately above
"Not Sleep", // Sleep name is tested separately above
"Sound Machine TAS — GDQ Studio",
new[] { new Person(1884, "Faith") },
[new Person(1884, "Faith")],
Enumerable.Empty<Person>(),
Enumerable.Empty<Person>()),

// Everyone
new(now, Duration.FromMinutes(15),
"Finale!",
"Finale% — GDQ",
new[] { new Person(1885, "Everyone!") },
[new Person(1885, "Everyone!")],
Enumerable.Empty<Person>(),
Enumerable.Empty<Person>()),

// Frame Fatales Interstitial Team
new(now, Duration.FromMinutes(30),
"Preshow",
"Preshow — GDQ",
new[] { new Person(2071, "Frame Fatales Interstitial Team") },
[new Person(2071, "Frame Fatales Interstitial Team")],
Enumerable.Empty<Person>(),
Enumerable.Empty<Person>()),
};
Expand Down
Loading

0 comments on commit af71b33

Please sign in to comment.