Experimental automated capturing of selected Kick streams

This commit is contained in:
barelyprofessional
2025-07-07 19:09:13 -05:00
parent 2088c4d102
commit bcc3bde6c9
4 changed files with 223 additions and 1 deletions

View File

@@ -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; }
/// <summary>
/// Whether to automatically capture a stream when it goes live using yt-dlp
/// </summary>
public bool AutoCapture { get; set; } = false;
}
public class CourtHearingModel

View File

@@ -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<List<KickChannelModel>>();
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)

View File

@@ -0,0 +1,143 @@
using System.Diagnostics;
using KfChatDotNetBot.Settings;
using NLog;
namespace KfChatDotNetBot.Services;
/// <summary>
/// Basic stream capture using yt-dlp in a separate window when CaptureAsync() is called
/// </summary>
/// <param name="streamUrl">Streamer URL</param>
/// <param name="ct">Cancellation token</param>
public class YtDlpCapture(string streamUrl, 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;
private readonly Logger _logger = LogManager.GetCurrentClassLogger();
/// <summary>
/// Initiates the capture of the stream itself by generating the underlying script and executing it
/// </summary>
/// <exception cref="UnsupportedOperatingSystemException">Thrown if the operating system is unsupported (i.e. not Windows, Linux or FreeBSD</exception>
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);
}
}
/// <summary>
/// Writes the script needed to perform the capture and returns the path of this script
/// </summary>
/// <returns>Absolute path of the script generated</returns>
/// <exception cref="UnsupportedOperatingSystemException">Thrown if the operating system is unsupported (i.e. not Windows, Linux or FreeBSD</exception>
private async Task<string> 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;

View File

@@ -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";
}
}