chore: initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
*.json
|
||||||
|
!appsettings.Example.json
|
||||||
+21
@@ -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.
|
||||||
@@ -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 }} "
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"ClientId": "ClientId",
|
||||||
|
"ClientSecret": "ClientSecret",
|
||||||
|
"RedirectUri": "RedirectUri",
|
||||||
|
"TokenUri": "TokenUri",
|
||||||
|
"AuthUri": "AuthUri",
|
||||||
|
"Scopes": "Scopes"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user