From f543f377f60b09d92f8d55829f0b3500b9a4c293 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Tue, 28 Oct 2025 16:10:44 -0500 Subject: [PATCH] chore: initial commit --- .gitignore | 2 + LICENSE.md | 21 +++ README.md | 28 ++++ appsettings.Example.json | 8 ++ index.cs | 269 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 328 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 appsettings.Example.json create mode 100644 index.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..878c04d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.json +!appsettings.Example.json \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..a2b2126 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) 2025 Stevan Freeborn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc2e423 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Viewer Count Fetcher + +This application fetches the live viewer count from a YouTube live broadcast using the YouTube Data API. It handles OAuth 2.0 authentication, including token refresh, and retrieves the current number of viewers for the user's active live broadcast. I use this currently to display my live viewer count as a segment in my terminal prompt while streaming. + +## Settings + +Create an `appsettings.json` file in the root of the project and supply the settings as shown in the `appsettings.Example.json` file. + +## Example Oh-My-Posh Segment + +```json +{ + "type": "command", + "style": "powerline", + "powerline_symbol": "\ue0b0", + "foreground": "#ffffff", + "background": "#ff0033", + "properties": { + "shell": "pwsh", + "command": "dotnet C:/Path/To/viewer-count-fetcher/index.cs" + }, + "cache": { + "duration": "30s", + "strategy": "session" + }, + "template": " \udb81\uddc3 {{ .Output }} " +} +``` diff --git a/appsettings.Example.json b/appsettings.Example.json new file mode 100644 index 0000000..a7b470b --- /dev/null +++ b/appsettings.Example.json @@ -0,0 +1,8 @@ +{ + "ClientId": "ClientId", + "ClientSecret": "ClientSecret", + "RedirectUri": "RedirectUri", + "TokenUri": "TokenUri", + "AuthUri": "AuthUri", + "Scopes": "Scopes" +} \ No newline at end of file diff --git a/index.cs b/index.cs new file mode 100644 index 0000000..c80d76f --- /dev/null +++ b/index.cs @@ -0,0 +1,269 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +try +{ + var settings = await LoadSettingsAsync(); + var tokenResponse = await GetTokenResponseAsync(settings); + var viewerCount = await GetLiveViewerCountAsync(tokenResponse); + Console.WriteLine(viewerCount); +} +catch (Exception ex) +{ + Console.WriteLine(ex.Message); +} + +#region Methods + +static async Task GetLiveViewerCountAsync(TokenResponse tokenResponse) +{ + using var client = new HttpClient(); + + var url = $"https://youtube.googleapis.com/youtube/v3/liveBroadcasts?part=statistics&broadcastStatus=active"; + + var requestUri = new Uri(url); + + using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenResponse.AccessToken); + + var response = await client.SendAsync(request); + + if (response.IsSuccessStatusCode is false) + { + throw new Exception("Failed to get broadcast"); + } + + var ytResponse = await response.Content.ReadFromJsonAsync(JsonContext.Default.YTResponse); + + if (ytResponse is null || ytResponse.Items.Length is 0) + { + throw new Exception(string.Empty); + } + + return ytResponse.Items[0].Stats.Viewers; +} + +static async Task GetTokenResponseAsync(Settings settings) +{ + var tokenResponsePath = Path.Combine(Directory.GetCurrentDirectory(), "tokenResponse.json"); + + if (File.Exists(tokenResponsePath) is false) + { + return await GetAccessTokenAsync(settings, tokenResponsePath); + } + else + { + var tokenResponseContent = await File.ReadAllTextAsync(tokenResponsePath); + var existingTokenResponse = JsonSerializer.Deserialize(tokenResponseContent, JsonContext.Default.TokenResponse) ?? + throw new Exception("Failed to parse existing token response"); + + var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + if (now >= existingTokenResponse.ExpiresAt) + { + return await RefreshTokenAsync(existingTokenResponse.RefreshToken, settings, tokenResponsePath); + } + + return existingTokenResponse; + } +} + +static async Task LoadSettingsAsync() +{ + var settingsPath = Path.Combine(Directory.GetCurrentDirectory(), "appsettings.json"); + var settingsContent = await File.ReadAllTextAsync(settingsPath); + var settings = JsonSerializer.Deserialize(settingsContent, JsonContext.Default.Settings); + + if (settings is null) + { + throw new Exception("Failed to load settings"); + } + + return settings; +} + +static async Task RefreshTokenAsync(string refreshToken, Settings settings, string tokenResponsePath) +{ + using var client = new HttpClient(); + + var uri = new Uri(settings.TokenUri, UriKind.Absolute); + + using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, uri) + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["client_id"] = settings.ClientId, + ["client_secret"] = settings.ClientSecret, + ["refresh_token"] = refreshToken, + ["grant_type"] = "refresh_token" + }) + }; + + var tokenResponse = await client.SendAsync(tokenRequest); + var tokenResponseContent = await tokenResponse.Content.ReadAsStringAsync(); + + if (tokenResponse.IsSuccessStatusCode is false) + { + throw new Exception("Failed to refresh OAuth token"); + } + + var newToken = JsonSerializer.Deserialize(tokenResponseContent, JsonContext.Default.TokenResponse) ?? + throw new Exception("Failed to parse refreshed OAuth token"); + + var newTokenResponse = newToken.WithExpiresAt() with { RefreshToken = refreshToken }; + + var tokenResponseJson = JsonSerializer.Serialize(newTokenResponse, JsonContext.Default.TokenResponse); + await File.WriteAllTextAsync(tokenResponsePath, tokenResponseJson); + return newTokenResponse; +} + +static async Task GetAccessTokenAsync(Settings settings, string tokenResponsePath) +{ + var authUri = GetOAuthUri(settings); + + using var process = Process.Start(new ProcessStartInfo + { + FileName = authUri, + UseShellExecute = true + }); + + using var listener = new HttpListener(); + listener.Prefixes.Add(settings.RedirectUri); + listener.Start(); + + var listenerContext = await listener.GetContextAsync(); + var oauthCode = listenerContext.Request.QueryString["code"]; + + try + { + var tokenResponse = await GetTokenAsync(oauthCode, settings); + var tokenResponseJson = JsonSerializer.Serialize(tokenResponse, JsonContext.Default.TokenResponse); + await File.WriteAllTextAsync(tokenResponsePath, tokenResponseJson); + return tokenResponse; + } + finally + { + var responseHtml = "

You may now close this window.

"; + var buffer = Encoding.UTF8.GetBytes(responseHtml); + listenerContext.Response.ContentLength64 = buffer.Length; + await listenerContext.Response.OutputStream.WriteAsync(buffer); + listenerContext.Response.Close(); + listener.Stop(); + } +} + +static string GetOAuthUri(Settings settings) +{ + var authUriQueryParams = new Dictionary + { + ["client_id"] = settings.ClientId, + ["redirect_uri"] = settings.RedirectUri, + ["response_type"] = "code", + ["scope"] = settings.Scopes, + ["access_type"] = "offline", + ["prompt"] = "consent", + }; + var query = string.Join("&", authUriQueryParams.Select(static kvp => $"{kvp.Key}={Uri.EscapeDataString(kvp.Value)}")); + var authUri = $"{settings.AuthUri}?{query}"; + + return authUri; +} + +static async Task GetTokenAsync(string? code, Settings settings) +{ + using var client = new HttpClient(); + + var uri = new Uri(settings.TokenUri, UriKind.Absolute); + + using var tokenRequest = new HttpRequestMessage(HttpMethod.Post, uri) + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["code"] = code, + ["client_id"] = settings.ClientId, + ["client_secret"] = settings.ClientSecret, + ["redirect_uri"] = settings.RedirectUri, + ["grant_type"] = "authorization_code" + }) + }; + + var tokenResponse = await client.SendAsync(tokenRequest); + var tokenResponseContent = await tokenResponse.Content.ReadAsStringAsync(); + + if (tokenResponse.IsSuccessStatusCode is false) + { + throw new Exception("Failed to get OAuth token"); + } + + var token = JsonSerializer.Deserialize(tokenResponseContent, JsonContext.Default.TokenResponse) ?? throw new Exception("Failed to parse OAuth token"); + return token.WithExpiresAt(); +} + +#endregion + +#region Models + +record Settings( + string ClientId, + string ClientSecret, + string RedirectUri, + string TokenUri, + string AuthUri, + string Scopes +); + +record YTResponse( + [property: JsonPropertyName("items")] + YTItem[] Items +); + +record YTItem( + [property: JsonPropertyName("statistics")] + YTStats Stats +); + +record YTStats( + [property: JsonPropertyName("concurrentViewers")] + int Viewers +); + +record TokenResponse( + [property: JsonPropertyName("access_token")] + string AccessToken, + [property: JsonPropertyName("expires_in")] + int ExpiresIn, + [property: JsonPropertyName("token_type")] + string TokenType, + [property: JsonPropertyName("scope")] + string Scope, + [property: JsonPropertyName("refresh_token")] + string RefreshToken, + [property: JsonPropertyName("expires_at")] + long ExpiresAt +) +{ + public TokenResponse WithExpiresAt() => this with + { + ExpiresAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + (ExpiresIn * 1000) + }; +} + +[JsonSourceGenerationOptions( + WriteIndented = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString +)] +[JsonSerializable(typeof(YTResponse))] +[JsonSerializable(typeof(YTItem))] +[JsonSerializable(typeof(YTStats))] +[JsonSerializable(typeof(Settings))] +[JsonSerializable(typeof(TokenResponse))] +internal sealed partial class JsonContext : JsonSerializerContext +{ +} + +#endregion \ No newline at end of file