Migrated streams from bespoke settings to a database table, added DLive support and Streamlink capturing with remux support

This commit is contained in:
barelyprofessional
2025-07-20 01:27:00 -05:00
parent c086ed350a
commit c134a6808d
13 changed files with 1046 additions and 170 deletions

View File

@@ -4,6 +4,7 @@ using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings;
using KickWsClient.Models;
using Microsoft.EntityFrameworkCore;
using NLog;
using Websocket.Client;
@@ -33,6 +34,7 @@ public class BotServices
private Yeet? _yeet;
public AlmanacShill? AlmanacShill;
private Parti? _parti;
private DLive? _dliveStatusCheck;
private Task? _websocketWatchdog;
private Task? _howlggGetUserTimer;
@@ -41,7 +43,6 @@ public class BotServices
private bool _twitchDisabled;
internal bool IsBmjLive;
private bool _isBmjLiveSynced;
internal bool IsChrisDjLive;
private Dictionary<string, SeenYeetBet> _yeetBets = new();
// lol
@@ -81,7 +82,8 @@ public class BotServices
BuildBetBolt(),
BuildYeet(),
BuildRainbet(),
BuildParti()
BuildParti(),
BuildDLiveStatusCheck()
];
try
{
@@ -240,9 +242,10 @@ public class BotServices
private async Task BuildKick()
{
await using var db = new ApplicationDbContext();
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.PusherEndpoint, BuiltIn.Keys.Proxy, BuiltIn.Keys.PusherReconnectTimeout,
BuiltIn.Keys.KickEnabled, BuiltIn.Keys.KickChannels
BuiltIn.Keys.KickEnabled
]);
KickClient = new KickWsClient.KickWsClient(settings[BuiltIn.Keys.PusherEndpoint].Value!,
settings[BuiltIn.Keys.Proxy].Value, settings[BuiltIn.Keys.PusherReconnectTimeout].ToType<int>());
@@ -256,11 +259,29 @@ public class BotServices
if (settings[BuiltIn.Keys.KickEnabled].ToBoolean())
{
await KickClient.StartWsClient();
var kickChannels = settings[BuiltIn.Keys.KickChannels].JsonDeserialize<List<KickChannelModel>>();
if (kickChannels == null) return;
var kickChannels = db.Streams.Where(s => s.Service == StreamService.Kick);
foreach (var channel in kickChannels)
{
KickClient.SendPusherSubscribe($"channel.{channel.ChannelId}");
if (channel.Metadata == null)
{
_logger.Error($"Row ID {channel.Id} in the Streams table has null Metadata when it is required for Kick");
continue;
}
KickStreamMetaModel meta;
try
{
meta = JsonSerializer.Deserialize<KickStreamMetaModel>(channel.Metadata) ??
throw new InvalidOperationException(
$"Caught a null when attempting to deserialize metadata for {channel.Id} in the Streams table");
}
catch (Exception e)
{
_logger.Error($"Failed to deserialize the metadata for {channel.Id} in the Streams table");
_logger.Error(e);
continue;
}
KickClient.SendPusherSubscribe($"channel.{meta.ChannelId}");
}
}
}
@@ -294,6 +315,13 @@ public class BotServices
AlmanacShill.StartShillTask();
_logger.Info("Built the almanac shill task");
}
private async Task BuildDLiveStatusCheck()
{
_dliveStatusCheck = new DLive(_chatBot);
_dliveStatusCheck.StartLiveStatusCheck();
_logger.Info("Built the DLive livestream status check task");
}
private async Task BuildParti()
{
@@ -841,16 +869,12 @@ public class BotServices
private void OnTwitchStreamStateUpdated(object sender, int channelId, bool isLive)
{
_logger.Info($"BossmanJack stream event came in. isLive => {isLive}");
var settings = SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.RestreamUrl, BuiltIn.Keys.TwitchBossmanJackUsername, BuiltIn.Keys.BotToyStoryImage]).Result;
var settings = SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.RestreamUrl, BuiltIn.Keys.TwitchBossmanJackUsername]).Result;
if (isLive)
{
_chatBot.SendChatMessage($"{settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value} just went live on Twitch! https://www.twitch.tv/{settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value}\r\n" +
settings[BuiltIn.Keys.RestreamUrl].Value);
if (IsChrisDjLive)
{
_chatBot.SendChatMessage($"[img]{settings[BuiltIn.Keys.BotToyStoryImage].Value}[/img]", true);
}
IsBmjLive = true;
return;
}
@@ -915,11 +939,30 @@ public class BotServices
private void OnPusherWsReconnected(object sender, ReconnectionInfo reconnectionInfo)
{
_logger.Error($"Pusher reconnected due to {reconnectionInfo.Type}");
var kickChannels = SettingsProvider.GetValueAsync(BuiltIn.Keys.KickChannels).Result.JsonDeserialize<List<KickChannelModel>>();
if (kickChannels == null) return;
using var db = new ApplicationDbContext();
var kickChannels = db.Streams.Where(s => s.Service == StreamService.Kick);
foreach (var channel in kickChannels)
{
KickClient?.SendPusherSubscribe($"channel.{channel.ChannelId}");
if (channel.Metadata == null)
{
_logger.Error($"Row ID {channel.Id} in the Streams table has null Metadata when it is required for Kick");
continue;
}
KickStreamMetaModel meta;
try
{
meta = JsonSerializer.Deserialize<KickStreamMetaModel>(channel.Metadata) ??
throw new InvalidOperationException(
$"Caught a null when attempting to deserialize metadata for {channel.Id} in the Streams table");
}
catch (Exception e)
{
_logger.Error($"Failed to deserialize the metadata for {channel.Id} in the Streams table");
_logger.Error(e);
continue;
}
KickClient?.SendPusherSubscribe($"channel.{meta.ChannelId}");
}
}
@@ -944,113 +987,142 @@ public class BotServices
{
if (e == null) return;
var settings = SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.KickChannels, BuiltIn.Keys.BotChrisDjLiveImage, BuiltIn.Keys.CaptureEnabled
BuiltIn.Keys.CaptureEnabled
]).Result;
var channels = settings[BuiltIn.Keys.KickChannels].JsonDeserialize<List<KickChannelModel>>();
if (channels == null)
using var db = new ApplicationDbContext();
var channels = db.Streams.Where(s => s.Service == StreamService.Kick).Include(s => s.User);
StreamDbModel? channel = null;
foreach (var ch in channels)
{
_logger.Error("Caught null when grabbing Kick channels");
return;
if (ch.Metadata == null)
{
_logger.Error($"Row ID {ch.Id} in the Streams table has null Metadata when it is required for Kick");
continue;
}
KickStreamMetaModel meta;
try
{
meta = JsonSerializer.Deserialize<KickStreamMetaModel>(ch.Metadata) ??
throw new InvalidOperationException(
$"Caught a null when attempting to deserialize metadata for {ch.Id} in the Streams table");
}
catch (Exception ex)
{
_logger.Error($"Failed to deserialize the metadata for {ch.Id} in the Streams table");
_logger.Error(ex);
continue;
}
if (meta.ChannelId != e.Livestream.ChannelId) continue;
channel = ch;
break;
}
var channel = channels.FirstOrDefault(ch => ch.ChannelId == e.Livestream.ChannelId);
if (channel == null)
{
_logger.Error($"Caught null when grabbing channel data for {e.Livestream.ChannelId}");
_logger.Error($"Failed to find a Kick stream in the database for {e.Livestream.ChannelId} which we got notified is live");
_logger.Error("This really should never happen, but could happen if the metadata for a stream gets screwed up at runtime");
return;
}
using var db = new ApplicationDbContext();
var user = db.Users.FirstOrDefault(u => u.KfId == channel.ForumId);
if (user == null)
var identity = "A streamer";
if (channel.User != null)
{
_logger.Error($"Caught null when retrieving forum user {channel.ForumId}");
return;
identity = "@" + channel.User.KfUsername;
}
_chatBot.SendChatMessage(
$"@{user.KfUsername} is live! {e.Livestream.SessionTitle} https://kick.com/{channel.ChannelSlug}", true);
if (channel.ChannelSlug == "christopherdj")
{
IsChrisDjLive = true;
_chatBot.SendChatMessage($"[img]{settings[BuiltIn.Keys.BotChrisDjLiveImage].Value}[/img]", true);
}
$"{identity} is live! {e.Livestream.SessionTitle} {channel.StreamUrl}", true);
if (channel.AutoCapture && settings[BuiltIn.Keys.CaptureEnabled].ToBoolean())
{
_logger.Info($"{channel.ChannelSlug} is configured to auto capture");
_ = new YtDlpCapture($"https://kick.com/{channel.ChannelSlug}", _cancellationToken).CaptureAsync();
_logger.Info($"{channel.StreamUrl} is configured to auto capture");
_ = new StreamCapture(channel.StreamUrl, StreamCaptureMethods.YtDlp, _cancellationToken).CaptureAsync();
}
}
private void OnPartiChannelLiveNotification(object sender, PartiChannelLiveNotificationModel data)
{
var settings = SettingsProvider
.GetMultipleValuesAsync([BuiltIn.Keys.PartiChannels, BuiltIn.Keys.CaptureEnabled]).Result;
var channels = settings[BuiltIn.Keys.PartiChannels].JsonDeserialize<List<PartiChannelModel>>();
if (channels == null)
{
_logger.Error("Caught a null when deserializing Parti channels");
return;
}
var channel = channels.FirstOrDefault(ch => ch.Username == data.Username);
if (channel == null)
{
_logger.Debug($"Got a Parti live notification for a channel we don't care about: {data.Username}");
return;
}
.GetMultipleValuesAsync([BuiltIn.Keys.CaptureEnabled]).Result;
using var db = new ApplicationDbContext();
var user = db.Users.FirstOrDefault(u => u.KfId == channel.ForumId);
if (user == null)
{
_logger.Error($"Caught a null when retrieving forum ID {channel.ForumId}");
return;
}
var url = $"https://parti.com/creator/{data.SocialMedia}/{data.Username}/";
if (data.SocialMedia == "discord")
{
url += "0";
}
_chatBot.SendChatMessage($"@{user.KfUsername} is live! {data.EventTitle} {url}", true);
var channel = db.Streams.Include(s => s.User)
.FirstOrDefault(s => s.Service == StreamService.Parti && s.StreamUrl == url);
if (channel == null)
{
_logger.Info($"Got a live notification from Parti for a stream we don't care about: {data.SocialMedia}/{data.Username}");
return;
}
var identity = "A streamer";
if (channel.User != null)
{
identity = "@" + channel.User.KfUsername;
}
_chatBot.SendChatMessage($"{identity} is live! {data.EventTitle} {url}", true);
if (channel.AutoCapture && settings[BuiltIn.Keys.CaptureEnabled].ToBoolean())
{
_logger.Info($"{channel.Username} is configured to auto capture");
_ = new YtDlpCapture(url, _cancellationToken).CaptureAsync();
_logger.Info($"{channel.StreamUrl} is configured to auto capture");
_ = new StreamCapture(url, StreamCaptureMethods.YtDlp, _cancellationToken).CaptureAsync();
}
}
private void OnStopStreamBroadcast(object sender, KickModels.StopStreamBroadcastEventModel? e)
{
if (e == null) return;
var channels = SettingsProvider.GetValueAsync(BuiltIn.Keys.KickChannels).Result.JsonDeserialize<List<KickChannelModel>>();
if (channels == null)
using var db = new ApplicationDbContext();
var channels = db.Streams.Where(s => s.Service == StreamService.Kick).Include(s => s.User);
StreamDbModel? channel = null;
foreach (var ch in channels)
{
_logger.Error("Caught null when grabbing Kick channels");
return;
if (ch.Metadata == null)
{
_logger.Error($"Row ID {ch.Id} in the Streams table has null Metadata when it is required for Kick");
continue;
}
KickStreamMetaModel meta;
try
{
meta = JsonSerializer.Deserialize<KickStreamMetaModel>(ch.Metadata) ??
throw new InvalidOperationException(
$"Caught a null when attempting to deserialize metadata for {ch.Id} in the Streams table");
}
catch (Exception ex)
{
_logger.Error($"Failed to deserialize the metadata for {ch.Id} in the Streams table");
_logger.Error(ex);
continue;
}
if (meta.ChannelId != e.Livestream.Id) continue;
channel = ch;
break;
}
var channel = channels.FirstOrDefault(ch => ch.ChannelId == e.Livestream.Channel.Id);
if (channel == null)
{
_logger.Error($"Caught null when grabbing channel data for {e.Livestream.Channel.Id}");
_logger.Error($"Failed to find a Kick stream in the database for {e.Livestream.Id} which we got notified is no longer live");
_logger.Error("This really should never happen, but could happen if the metadata for a stream gets screwed up at runtime");
return;
}
using var db = new ApplicationDbContext();
var user = db.Users.FirstOrDefault(u => u.KfId == channel.ForumId);
if (user == null)
var identity = "A streamer";
if (channel.User != null)
{
_logger.Error($"Caught null when retrieving forum user {channel.ForumId}");
return;
identity = "@" + channel.User.KfUsername;
}
_chatBot.SendChatMessage(
$"@{user.KfUsername} is no longer live! :lossmanjack:", true);
if (channel.ChannelSlug == "christopherdj") IsChrisDjLive = false;
$"{identity} is no longer live! :lossmanjack:", true);
}
public async Task<bool> CheckBmjIsLive(string bmjUsername)

View File

@@ -0,0 +1,150 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using KfChatDotNetBot.Models;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings;
using Microsoft.EntityFrameworkCore;
using NLog;
namespace KfChatDotNetBot.Services;
public class DLive(ChatBot kfChatBot) : IDisposable
{
private readonly Logger _logger = LogManager.GetCurrentClassLogger();
private Task? _liveStatusCheckTask;
private CancellationTokenSource _liveStatusCheckTaskCts = new();
public void StartLiveStatusCheck()
{
_liveStatusCheckTaskCts = new CancellationTokenSource();
_liveStatusCheckTask = Task.Run(LiveStatusCheckTask, _liveStatusCheckTaskCts.Token);
}
private async Task LiveStatusCheckTask()
{
var interval = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.DLiveCheckInterval)).ToType<int>();
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(interval));
while (await timer.WaitForNextTickAsync(_liveStatusCheckTaskCts.Token))
{
var ct = _liveStatusCheckTaskCts.Token;
_logger.Debug("Going to check if anyone is live on DLive now");
await using var db = new ApplicationDbContext();
var streams = await db.Streams.Where(s => s.Service == StreamService.DLive).Include(s => s.User).ToListAsync(ct);
var settings = await SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.DLivePersistedCurrentlyLiveStreams, BuiltIn.Keys.CaptureEnabled
]);
var currentlyLive = settings[BuiltIn.Keys.DLivePersistedCurrentlyLiveStreams].JsonDeserialize<List<string>>() ?? [];
foreach (var stream in streams)
{
var username = stream.StreamUrl.Split('/').LastOrDefault();
if (username == null)
{
_logger.Error($"Could not determine the DLive username from {stream.StreamUrl} in row {stream.Id}");
continue;
}
var status = await IsLive(username, ct);
if (!status.IsLive)
{
currentlyLive.Remove(username);
continue;
}
// Already known to be live so do nothing
if (currentlyLive.Contains(username)) continue;
var identity = "A streamer";
if (stream.User != null)
{
identity = "@" + stream.User.KfUsername;
}
await kfChatBot.SendChatMessageAsync($"{identity} is live! {status.Title} {stream.StreamUrl}", true);
if (stream.AutoCapture && settings[BuiltIn.Keys.CaptureEnabled].ToBoolean())
{
_logger.Info($"{stream.StreamUrl} is live and set to auto capture");
_ = new StreamCapture(stream.StreamUrl, StreamCaptureMethods.Streamlink, ct).CaptureAsync();
}
currentlyLive.Add(username);
}
_logger.Debug($"Persisting currently live streams, count is {currentlyLive.Count}");
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.DLivePersistedCurrentlyLiveStreams,
currentlyLive);
}
}
public static async Task<DLiveIsLiveModel> IsLive(string username, CancellationToken ct = default)
{
var logger = LogManager.GetCurrentClassLogger();
var proxy = await SettingsProvider.GetValueAsync(BuiltIn.Keys.Proxy);
var handler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.All,
};
if (proxy.Value != null)
{
handler.Proxy = new WebProxy(proxy.Value);
handler.UseProxy = true;
logger.Debug($"Set proxy for the DLive GraphQL request to {proxy.Value}");
}
var gql = "query { userByDisplayName(displayname:\"" + username + "\") { livestream " +
"{ content createdAt title thumbnailUrl watchingCount } username } }";
logger.Debug($"Built GraphQL query string: {gql}");
var jsonBody = new Dictionary<string, object>
{
{ "query", gql }
};
logger.Debug("Created dictionary object for the JSON payload, should serialize to following:");
logger.Debug(JsonSerializer.Serialize(jsonBody));
using var client = new HttpClient(handler);
client.DefaultRequestHeaders.Accept.Clear();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var postBody = JsonContent.Create(jsonBody);
var response = await client.PostAsync("https://graphigo.prd.dlive.tv/", postBody, ct);
var content = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
logger.Debug("DLive GraphQL endpoint returned the following JSON");
logger.Debug(content.GetRawText);
// Not live
// {"data":{"userByDisplayName":{"livestream":null,"username":"planesfan"}}}
// Live
// {
// "data": {
// "userByDisplayName": {
// "livestream": {
// "content": "",
// "createdAt": "1752699050000",
// "title": "HUNT FULL ACHAT VENEZ DONNER VOS CALL!!!",
// "thumbnailUrl": "https://images.prd.dlivecdn.com/live-thumbnail/a587c2a8-6288-11f0-90fc-d638708e4bb8",
// "watchingCount": 799
// },
// "username": "cashpistache1"
// }
// }
// }
var responseData = content.GetProperty("data").GetProperty("userByDisplayName");
var isLive = responseData.GetProperty("livestream").ValueKind == JsonValueKind.Object;
string? title = null;
if (isLive)
{
title = responseData.GetProperty("livestream").GetProperty("title").GetString();
}
return new DLiveIsLiveModel
{
IsLive = isLive,
Title = title,
Username = responseData.GetProperty("username").GetString() ?? "username was null in GraphQL response"
};
}
public void Dispose()
{
_liveStatusCheckTaskCts.Cancel();
_liveStatusCheckTask?.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -9,12 +9,13 @@ namespace KfChatDotNetBot.Services;
/// </summary>
/// <param name="streamUrl">Streamer URL</param>
/// <param name="ct">Cancellation token</param>
public class YtDlpCapture(string streamUrl, CancellationToken ct = default)
public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod, CancellationToken ct = default)
{
private readonly Dictionary<string, Setting> _settings = SettingsProvider
.GetMultipleValuesAsync([BuiltIn.Keys.CaptureYtDlpBinaryPath, BuiltIn.Keys.CaptureYtDlpWorkingDirectory,
BuiltIn.Keys.CaptureYtDlpCookiesFromBrowser, BuiltIn.Keys.CaptureYtDlpOutputFormat, BuiltIn.Keys.CaptureYtDlpParentTerminal,
BuiltIn.Keys.CaptureYtDlpScriptPath, BuiltIn.Keys.CaptureYtDlpUserAgent]).Result;
BuiltIn.Keys.CaptureYtDlpScriptPath, BuiltIn.Keys.CaptureYtDlpUserAgent, BuiltIn.Keys.CaptureStreamlinkBinaryPath,
BuiltIn.Keys.CaptureStreamlinkOutputFormat, BuiltIn.Keys.CaptureStreamlinkRemuxScript]).Result;
private readonly Logger _logger = LogManager.GetCurrentClassLogger();
@@ -82,6 +83,7 @@ public class YtDlpCapture(string streamUrl, CancellationToken ct = default)
_logger.Error("Capture process task faulted");
_logger.Error(task.Exception);
}
_logger.Info($"Script {pStartInfoExecuteScript} launched and yielded to us!");
}
/// <summary>
@@ -99,10 +101,31 @@ public class YtDlpCapture(string streamUrl, CancellationToken ct = default)
}
_logger.Info($"Generated script path: {scriptPath}");
var ytDlpLine = $"{_settings[BuiltIn.Keys.CaptureYtDlpBinaryPath].Value} -o \"{_settings[BuiltIn.Keys.CaptureYtDlpOutputFormat].Value}\" " +
$"--user-agent \"{_settings[BuiltIn.Keys.CaptureYtDlpUserAgent].Value}\" " +
$"--cookies-from-browser {_settings[BuiltIn.Keys.CaptureYtDlpCookiesFromBrowser].Value} " +
$"--write-info-json --wait-for-video 15 {streamUrl}";
string captureLine;
if (captureMethod == StreamCaptureMethods.YtDlp)
{
captureLine = $"{_settings[BuiltIn.Keys.CaptureYtDlpBinaryPath].Value} -o \"{_settings[BuiltIn.Keys.CaptureYtDlpOutputFormat].Value}\" " +
$"--user-agent \"{_settings[BuiltIn.Keys.CaptureYtDlpUserAgent].Value}\" " +
$"--cookies-from-browser {_settings[BuiltIn.Keys.CaptureYtDlpCookiesFromBrowser].Value} " +
$"--write-info-json --wait-for-video 15 {streamUrl}";
}
else if (captureMethod == StreamCaptureMethods.Streamlink)
{
captureLine = $"{_settings[BuiltIn.Keys.CaptureStreamlinkBinaryPath].Value} --output \"{_settings[BuiltIn.Keys.CaptureStreamlinkOutputFormat].Value}\" " +
$"--retry-streams 15 --retry-max 10 {streamUrl} best";
}
else
{
_logger.Error($"We were given a straem capture method that doesn't exist: {captureMethod}");
throw new UnsupportedStreamCaptureMethodException();
}
var remuxLine = string.Empty;
if (captureMethod == StreamCaptureMethods.Streamlink)
{
remuxLine = _settings[BuiltIn.Keys.CaptureStreamlinkRemuxScript].Value;
}
string scriptContent;
if (OperatingSystem.IsWindows())
@@ -111,14 +134,16 @@ public class YtDlpCapture(string streamUrl, CancellationToken ct = default)
// we'll need to swap to that drive letter, so this just trims off the \ to transform it to D: or whatever. UNC paths not supported
scriptContent = $"{Path.GetPathRoot(_settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value)?.TrimEnd('\\')}{Environment.NewLine}" +
$"CD {_settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value}{Environment.NewLine}" +
$"{ytDlpLine}{Environment.NewLine}" +
$"{captureLine}{Environment.NewLine}" +
$"{remuxLine}{Environment.NewLine}" +
$"PAUSE";
}
else if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD())
{
scriptContent = $"#!/bin/bash{Environment.NewLine}" +
$"cd {_settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value}{Environment.NewLine}" +
$"{ytDlpLine}{Environment.NewLine}" +
$"{captureLine}{Environment.NewLine}" +
$"{remuxLine}{Environment.NewLine}" +
$"read -p \"Press enter to exit\"";
}
else
@@ -140,4 +165,12 @@ public class YtDlpCapture(string streamUrl, CancellationToken ct = default)
}
}
public class UnsupportedOperatingSystemException : Exception;
public class UnsupportedOperatingSystemException : Exception;
public class UnsupportedStreamCaptureMethodException : Exception;
public enum StreamCaptureMethods
{
YtDlp,
Streamlink
}