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