Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion Jobs/TwitchLiveJob.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ public async Task Execute(IJobExecutionContext context)
return;

List<string> userIds = subscriptions.Select(s => s.TwitchUserId).Distinct().ToList();
IReadOnlyDictionary<string, TwitchService.TwitchStream> live = await twitch.GetLiveStreamsAsync(userIds);
TwitchService.LiveStreamsResult result = await twitch.GetLiveStreamsResultAsync(userIds, context.CancellationToken);
if (!result.Succeeded)
{
logsService.Log("TwitchLiveJob: live-status request failed; preserving existing subscription state.", LogSeverity.Warning);
return;
}

IReadOnlyDictionary<string, TwitchService.TwitchStream> live = result.Streams;

bool changed = false;

Expand Down
31 changes: 31 additions & 0 deletions Morpheus.Tests/TwitchServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ public void CalculateTokenCacheDuration_NeverCachesBeyondExpiry(int expiresInSec
Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), duration);
}

[Fact]
public async Task GetLiveStreamsResultAsync_WhenStreamsRequestFails_MarksResultAsUnknown()
{
using HttpClient httpClient = new(new StreamsFailureHandler());
TwitchService service = new(new LogsService(new LogQueue()), httpClient, "test-client", "test-secret");

TwitchService.LiveStreamsResult result = await service.GetLiveStreamsResultAsync(["123"]);

Assert.False(result.Succeeded);
Assert.Empty(result.Streams);
}

private sealed class CancellationHandler(bool blockTokenRequest) : HttpMessageHandler
{
private readonly TaskCompletionSource requestStarted = new(TaskCreationOptions.RunContinuationsAsynchronously);
Expand All @@ -56,4 +68,23 @@ protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage
throw new InvalidOperationException("The canceled Twitch request unexpectedly completed.");
}
}

private sealed class StreamsFailureHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.RequestUri?.Host == "id.twitch.tv")
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"access_token\":\"token\",\"expires_in\":3600}", Encoding.UTF8, "application/json")
});
}

return Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable)
{
Content = new StringContent("temporarily unavailable")
});
}
}
}
17 changes: 14 additions & 3 deletions Services/TwitchService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ internal TwitchService(LogsService logsService, HttpClient httpClient, string? c

public record TwitchUser(string Id, string Login, string DisplayName, string? ProfileImageUrl);
public record TwitchStream(string Id, string Title);
public record LiveStreamsResult(IReadOnlyDictionary<string, TwitchStream> Streams, bool Succeeded);

/// <summary>Resolves a Twitch login (handle) to its user, or null if not found / not configured.</summary>
public async Task<TwitchUser?> GetUserAsync(string login, CancellationToken ct = default)
Expand All @@ -67,10 +68,20 @@ public record TwitchStream(string Id, string Title);
/// present in the result are offline. Empty if not configured.
/// </summary>
public async Task<IReadOnlyDictionary<string, TwitchStream>> GetLiveStreamsAsync(IReadOnlyCollection<string> userIds, CancellationToken ct = default)
{
LiveStreamsResult result = await GetLiveStreamsResultAsync(userIds, ct);
return result.Streams;
}

/// <summary>
/// Returns currently-live streams together with whether every Twitch request completed.
/// An unsuccessful result must not be interpreted as "everyone is offline" by polling jobs.
/// </summary>
public async Task<LiveStreamsResult> GetLiveStreamsResultAsync(IReadOnlyCollection<string> userIds, CancellationToken ct = default)
{
Dictionary<string, TwitchStream> live = new();
if (!IsConfigured || userIds.Count == 0)
return live;
return new LiveStreamsResult(live, true);

// Helix allows up to 100 user_id params per request.
foreach (string[] batch in userIds.Distinct().Chunk(100))
Expand All @@ -79,7 +90,7 @@ public async Task<IReadOnlyDictionary<string, TwitchStream>> GetLiveStreamsAsync
string url = $"https://api.twitch.tv/helix/streams?{query}";
HelixStreamsResponse? resp = await SendHelixAsync<HelixStreamsResponse>(url, ct);
if (resp?.Data == null)
continue;
return new LiveStreamsResult(live, false);

foreach (StreamPayload s in resp.Data)
{
Expand All @@ -88,7 +99,7 @@ public async Task<IReadOnlyDictionary<string, TwitchStream>> GetLiveStreamsAsync
}
}

return live;
return new LiveStreamsResult(live, true);
}

private async Task<T?> SendHelixAsync<T>(string url, CancellationToken ct) where T : class
Expand Down