chore: initial commit

This commit is contained in:
Stevan Freeborn
2025-10-28 16:10:44 -05:00
commit f543f377f6
5 changed files with 328 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
*.json
!appsettings.Example.json
+21
View File
@@ -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.
+28
View File
@@ -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 }} "
}
```
+8
View File
@@ -0,0 +1,8 @@
{
"ClientId": "ClientId",
"ClientSecret": "ClientSecret",
"RedirectUri": "RedirectUri",
"TokenUri": "TokenUri",
"AuthUri": "AuthUri",
"Scopes": "Scopes"
}
+269
View File
@@ -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<int> 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<TokenResponse> 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<Settings> 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<TokenResponse> 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<string, string?>
{
["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<TokenResponse> 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 = "<html><body><h1>You may now close this window.</h1></body></html>";
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<string, string>
{
["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<TokenResponse> 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<string, string?>
{
["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