From bcc3bde6c9b5cef639e61f3b4a46bedbe024c184 Mon Sep 17 00:00:00 2001
From: barelyprofessional
<150058423+barelyprofessional@users.noreply.github.com>
Date: Mon, 7 Jul 2025 19:09:13 -0500
Subject: [PATCH] Experimental automated capturing of selected Kick streams
---
KfChatDotNetBot/Models/BotServicesModels.cs | 4 +
KfChatDotNetBot/Services/BotServices.cs | 10 +-
KfChatDotNetBot/Services/YtDlpCapture.cs | 143 ++++++++++++++++++++
KfChatDotNetBot/Settings/BuiltIn.cs | 67 +++++++++
4 files changed, 223 insertions(+), 1 deletion(-)
create mode 100644 KfChatDotNetBot/Services/YtDlpCapture.cs
diff --git a/KfChatDotNetBot/Models/BotServicesModels.cs b/KfChatDotNetBot/Models/BotServicesModels.cs
index 7eedf25..6ac6a6d 100644
--- a/KfChatDotNetBot/Models/BotServicesModels.cs
+++ b/KfChatDotNetBot/Models/BotServicesModels.cs
@@ -5,6 +5,10 @@ public class KickChannelModel
public required int ChannelId { get; set; }
public required int ForumId { get; set; }
public required string ChannelSlug { get; set; }
+ ///
+ /// Whether to automatically capture a stream when it goes live using yt-dlp
+ ///
+ public bool AutoCapture { get; set; } = false;
}
public class CourtHearingModel
diff --git a/KfChatDotNetBot/Services/BotServices.cs b/KfChatDotNetBot/Services/BotServices.cs
index 6400a48..46fa6ea 100644
--- a/KfChatDotNetBot/Services/BotServices.cs
+++ b/KfChatDotNetBot/Services/BotServices.cs
@@ -864,7 +864,9 @@ public class BotServices
private void OnStreamerIsLive(object sender, KickModels.StreamerIsLiveEventModel? e)
{
if (e == null) return;
- var settings = SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.KickChannels, BuiltIn.Keys.BotChrisDjLiveImage]).Result;
+ var settings = SettingsProvider.GetMultipleValuesAsync([
+ BuiltIn.Keys.KickChannels, BuiltIn.Keys.BotChrisDjLiveImage, BuiltIn.Keys.CaptureEnabled
+ ]).Result;
var channels = settings[BuiltIn.Keys.KickChannels].JsonDeserialize>();
if (channels == null)
{
@@ -895,6 +897,12 @@ public class BotServices
IsChrisDjLive = true;
_chatBot.SendChatMessage($"[img]{settings[BuiltIn.Keys.BotChrisDjLiveImage].Value}[/img]", 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();
+ }
}
private void OnStopStreamBroadcast(object sender, KickModels.StopStreamBroadcastEventModel? e)
diff --git a/KfChatDotNetBot/Services/YtDlpCapture.cs b/KfChatDotNetBot/Services/YtDlpCapture.cs
new file mode 100644
index 0000000..0519a7a
--- /dev/null
+++ b/KfChatDotNetBot/Services/YtDlpCapture.cs
@@ -0,0 +1,143 @@
+using System.Diagnostics;
+using KfChatDotNetBot.Settings;
+using NLog;
+
+namespace KfChatDotNetBot.Services;
+
+///
+/// Basic stream capture using yt-dlp in a separate window when CaptureAsync() is called
+///
+/// Streamer URL
+/// Cancellation token
+public class YtDlpCapture(string streamUrl, CancellationToken ct = default)
+{
+ private readonly Dictionary _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;
+
+ private readonly Logger _logger = LogManager.GetCurrentClassLogger();
+
+ ///
+ /// Initiates the capture of the stream itself by generating the underlying script and executing it
+ ///
+ /// Thrown if the operating system is unsupported (i.e. not Windows, Linux or FreeBSD
+ public async Task CaptureAsync()
+ {
+ var scriptPath = await CreateScriptAsync();
+
+ string pStartInfoFileName;
+ string pStartInfoExecuteArgument;
+ string pStartInfoExecuteScript;
+ if (OperatingSystem.IsWindows())
+ {
+ pStartInfoFileName = "cmd.exe";
+ pStartInfoExecuteArgument = "/C";
+ pStartInfoExecuteScript = $"START {scriptPath}";
+ }
+ else if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD())
+ {
+ pStartInfoFileName = _settings[BuiltIn.Keys.CaptureYtDlpParentTerminal].Value!;
+ pStartInfoExecuteArgument = "-x";
+ pStartInfoExecuteScript = scriptPath;
+ }
+ else
+ {
+ throw new UnsupportedOperatingSystemException();
+ }
+
+ using var process = Process.Start(new ProcessStartInfo
+ {
+ FileName = pStartInfoFileName,
+ ArgumentList = { pStartInfoExecuteArgument, pStartInfoExecuteScript },
+ WorkingDirectory = _settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value
+ });
+
+ if (process == null)
+ {
+ _logger.Error("Caught a null when trying to launch the capture process. Variables follow");
+ _logger.Error($"pStartInfoFileName = {pStartInfoFileName}");
+ _logger.Error($"pStartInfoExecuteArgument = {pStartInfoExecuteArgument}");
+ _logger.Error($"pStartInfoExecuteScript = {pStartInfoExecuteScript}");
+ return;
+ }
+
+ var task = process.WaitForExitAsync(ct);
+ // The process is supposed to exit almost immediately as the actual work is done in a separate terminal.
+ // Therefore, any laggards will be killed, as it's not how this is meant to be implemented.
+ // The capture should survive the bot being restarted.
+ try
+ {
+ await task.WaitAsync(TimeSpan.FromSeconds(15), ct);
+ }
+ catch (Exception e)
+ {
+ _logger.Error("Caught an exception while waiting for the capture process task");
+ _logger.Error(e);
+ return;
+ }
+
+ if (task.IsFaulted)
+ {
+ _logger.Error("Capture process task faulted");
+ _logger.Error(task.Exception);
+ }
+ }
+
+ ///
+ /// Writes the script needed to perform the capture and returns the path of this script
+ ///
+ /// Absolute path of the script generated
+ /// Thrown if the operating system is unsupported (i.e. not Windows, Linux or FreeBSD
+ private async Task CreateScriptAsync()
+ {
+ var scriptPath = Path.Join(_settings[BuiltIn.Keys.CaptureYtDlpScriptPath].Value,
+ $"bot_ytdlp_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}.sh");
+ if (OperatingSystem.IsWindows())
+ {
+ Path.ChangeExtension(scriptPath, ".bat");
+ }
+ _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 {streamUrl}";
+ string scriptContent;
+
+ if (OperatingSystem.IsWindows())
+ {
+ // GetPathRoot on Windows returns the top level directory, e.g. "C:\". Assuming the working directory is on another drive such as D:
+ // 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}" +
+ $"PAUSE";
+ }
+ else if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD())
+ {
+ scriptContent = $"#!/bin/bash{Environment.NewLine}" +
+ $"cd {_settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value}{Environment.NewLine}" +
+ $"{ytDlpLine}{Environment.NewLine}" +
+ $"read -p \"Press enter to exit\"";
+ }
+ else
+ {
+ throw new UnsupportedOperatingSystemException();
+ }
+
+ _logger.Info("Wrote the script, contents follow this message");
+ _logger.Info(scriptContent);
+
+ await File.WriteAllTextAsync(scriptPath, scriptContent, ct);
+ if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD())
+ {
+ File.SetUnixFileMode(scriptPath, UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute);
+ _logger.Info($"Marked {scriptPath} as executable since we're on Linux or FreeBSD");
+ }
+
+ return scriptPath;
+ }
+}
+
+public class UnsupportedOperatingSystemException : Exception;
\ No newline at end of file
diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs
index 1f15667..ea7a489 100644
--- a/KfChatDotNetBot/Settings/BuiltIn.cs
+++ b/KfChatDotNetBot/Settings/BuiltIn.cs
@@ -718,6 +718,65 @@ public static class BuiltIn
Default = "30",
ValueType = SettingValueType.Text
},
+ new BuiltInSettingsModel
+ {
+ Key = Keys.CaptureYtDlpOutputFormat,
+ Description = "Output format to pass to yt-dlp using -o",
+ Default = "%(title)s - %(uploader)s [%(id)s] %(upload_date)s %(timestamp)s.mp4",
+ ValueType = SettingValueType.Text
+ },
+ new BuiltInSettingsModel
+ {
+ Key = Keys.CaptureYtDlpWorkingDirectory,
+ Description = "Working directory set when running yt-dlp",
+ Default = "/tmp/discord/",
+ ValueType = SettingValueType.Text
+ },
+ new BuiltInSettingsModel
+ {
+ Key = Keys.CaptureYtDlpBinaryPath,
+ Description = "Path of the yt-dlp binary",
+ Default = "/usr/local/bin/yt-dlp",
+ ValueType = SettingValueType.Text
+ },
+ new BuiltInSettingsModel
+ {
+ Key = Keys.CaptureYtDlpUserAgent,
+ Description = "User-Agent that gets passed to yt-dlp --user-agent",
+ Default = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
+ ValueType = SettingValueType.Text
+ },
+ new BuiltInSettingsModel
+ {
+ Key = Keys.CaptureYtDlpCookiesFromBrowser,
+ Description = "What browser to pass to yt-dlp --cookies-from-browser",
+ Default = "firefox",
+ ValueType = SettingValueType.Text
+ },
+ new BuiltInSettingsModel
+ {
+ Key = Keys.CaptureEnabled,
+ Description = "Whether the auto-capture system is enabled",
+ Regex = "(true|false)",
+ Default = "true",
+ ValueType = SettingValueType.Boolean
+ },
+ new BuiltInSettingsModel
+ {
+ Key = Keys.CaptureYtDlpParentTerminal,
+ Description = "Parent terminal to launch the capture inside of, e.g. xfce4-terminal, mate-terminal, etc. " +
+ "Terminal must support -x. The process detaches from the bot so the bot can be freely restarted while a capture is running. " +
+ "Not supported on Windows, the bot will use cmd /C + START on Windows.",
+ Default = "/usr/bin/mate-terminal",
+ ValueType = SettingValueType.Text
+ },
+ new BuiltInSettingsModel
+ {
+ Key = Keys.CaptureYtDlpScriptPath,
+ Description = "Path to store the temporary .sh script used to initiate the capture",
+ Default = "/tmp/",
+ ValueType = SettingValueType.Text
+ }
];
public static class Keys
@@ -799,5 +858,13 @@ public static class BuiltIn
public static string YeetBmjUsernames = "Yeet.BmjUsernames";
public static string YeetProxy = "Yeet.Proxy";
public static string MomCooldown = "Mom.Cooldown";
+ public static string CaptureYtDlpOutputFormat = "Capture.YtDlp.OutputFormat";
+ public static string CaptureYtDlpWorkingDirectory = "Capture.YtDlp.WorkingDirectory";
+ public static string CaptureYtDlpBinaryPath = "Capture.YtDlp.BinaryPath";
+ public static string CaptureYtDlpUserAgent = "Capture.YtDlp.UserAgent";
+ public static string CaptureYtDlpCookiesFromBrowser = "Capture.YtDlp.CookiesFromBrowser";
+ public static string CaptureEnabled = "Capture.Enabled";
+ public static string CaptureYtDlpParentTerminal = "Capture.YtDlp.ParentTerminal";
+ public static string CaptureYtDlpScriptPath = "Capture.YtDlp.ScriptPath";
}
}
\ No newline at end of file