using System.Diagnostics; using KfChatDotNetBot.Models.DbModels; 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 StreamCapture(string streamUrl, StreamCaptureMethods captureMethod, CaptureOverridesModel? captureOverrides = null, 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, BuiltIn.Keys.CaptureStreamlinkBinaryPath, BuiltIn.Keys.CaptureStreamlinkOutputFormat, BuiltIn.Keys.CaptureStreamlinkRemuxScript, BuiltIn.Keys.CaptureStreamlinkTwitchOptions, BuiltIn.Keys.CaptureLockTable ]).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 = captureOverrides?.CaptureYtDlpParentTerminal ?? _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 = captureOverrides?.CaptureYtDlpWorkingDirectory ?? _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); } _logger.Info($"Script {pStartInfoExecuteScript} launched and yielded to us!"); } /// /// 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 random = Convert.ToHexString(Guid.NewGuid().ToByteArray()[..4]); var scriptPath = Path.Join(_settings[captureOverrides?.CaptureYtDlpScriptPath ?? BuiltIn.Keys.CaptureYtDlpScriptPath].Value, $"bot_ytdlp_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{random}.sh"); var lockFile = Path.Join(_settings[captureOverrides?.CaptureYtDlpScriptPath ?? BuiltIn.Keys.CaptureYtDlpScriptPath].Value, $"capture_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{random}.lock"); if (OperatingSystem.IsWindows()) { Path.ChangeExtension(scriptPath, ".bat"); } _logger.Info($"Generated script path: {scriptPath}"); _logger.Info($"Generated lock file path: {lockFile}"); string captureLine; if (captureMethod == StreamCaptureMethods.YtDlp) { captureLine = $"{captureOverrides?.CaptureYtDlpBinaryPath ?? _settings[BuiltIn.Keys.CaptureYtDlpBinaryPath].Value} " + $"-o \"{captureOverrides?.CaptureYtDlpOutputFormat ?? _settings[BuiltIn.Keys.CaptureYtDlpOutputFormat].Value}\" " + $"--user-agent \"{captureOverrides?.CaptureYtDlpUserAgent ?? _settings[BuiltIn.Keys.CaptureYtDlpUserAgent].Value}\" " + $"--cookies-from-browser {captureOverrides?.CaptureYtDlpCookiesFromBrowser ?? _settings[BuiltIn.Keys.CaptureYtDlpCookiesFromBrowser].Value} " + $"--write-info-json --wait-for-video 15 --merge-output-format mp4 --verbose {streamUrl}"; } else if (captureMethod == StreamCaptureMethods.Streamlink) { var twitchOpts = string.Empty; if (streamUrl.Contains("twitch.tv")) { twitchOpts = captureOverrides?.CaptureStreamlinkTwitchOptions ?? _settings[BuiltIn.Keys.CaptureStreamlinkTwitchOptions].Value; } captureLine = $"{captureOverrides?.CaptureStreamlinkBinaryPath ?? _settings[BuiltIn.Keys.CaptureStreamlinkBinaryPath].Value} {twitchOpts} " + $"--output \"{captureOverrides?.CaptureStreamlinkOutputFormat ?? _settings[BuiltIn.Keys.CaptureStreamlinkOutputFormat].Value}\" " + $"--retry-streams 15 --retry-max 10 --hls-segment-queue-threshold 0 {streamUrl} best"; } else { _logger.Error($"We were given a stream capture method that doesn't exist: {captureMethod}"); throw new UnsupportedStreamCaptureMethodException(); } var remuxLine = string.Empty; if (captureMethod == StreamCaptureMethods.Streamlink) { remuxLine = captureOverrides?.CaptureStreamlinkRemuxScript ?? _settings[BuiltIn.Keys.CaptureStreamlinkRemuxScript].Value; } 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 = $"TYPE nul > {lockFile}{Environment.NewLine}" + $"{Path.GetPathRoot(captureOverrides?.CaptureYtDlpWorkingDirectory ?? _settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value)?.TrimEnd('\\')}{Environment.NewLine}" + $"CD {captureOverrides?.CaptureYtDlpWorkingDirectory ?? _settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value}{Environment.NewLine}" + $"{captureLine}{Environment.NewLine}" + $"DEL /q {lockFile}{Environment.NewLine}" + $"{remuxLine}{Environment.NewLine}" + $"PAUSE"; } else if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD()) { scriptContent = $"#!/bin/bash{Environment.NewLine}" + $"touch {lockFile}{Environment.NewLine}" + $"cd {captureOverrides?.CaptureYtDlpWorkingDirectory ?? _settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value}{Environment.NewLine}" + $"{captureLine}{Environment.NewLine}" + $"rm -f {lockFile}{Environment.NewLine}" + $"{remuxLine}{Environment.NewLine}" + $"read -p \"Press enter to exit\""; } else { throw new UnsupportedOperatingSystemException(); } _logger.Info("Wrote the script, contents follow this message"); _logger.Info(scriptContent); _logger.Info("Updating locktable"); var table = _settings[BuiltIn.Keys.CaptureLockTable].JsonDeserialize>() ?? new Dictionary(); if (!table.TryAdd(streamUrl, lockFile)) { _logger.Info($"{streamUrl} has a lockfile already pointing to {table[streamUrl]}, updating to {lockFile}"); table[streamUrl] = lockFile; } await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.CaptureLockTable, table); 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; public class UnsupportedStreamCaptureMethodException : Exception; public enum StreamCaptureMethods { YtDlp, Streamlink }