Added support for selectively overriding capture settings on a per-stream basis

This commit is contained in:
barelyprofessional
2025-09-14 01:05:37 -05:00
parent d76f427621
commit 15de60e60b
7 changed files with 96 additions and 26 deletions

View File

@@ -33,12 +33,35 @@ public enum StreamService
KiwiPeerTube KiwiPeerTube
} }
public class KickStreamMetaModel public class BaseMetaModel
{
public CaptureOverridesModel? CaptureOverrides { get; set; } = null;
}
public class CaptureOverridesModel
{
// Options applicable to YtDlp will not work with Streamlink and vice versa
// That being said, some options are shared while still being explicitly marked as YtDlp
// This applies to CaptureYtDlpWorkingDirectory, CaptureYtDlpParentTerminal, and CaptureYtDlpScriptPath
public string? CaptureYtDlpBinaryPath { get; set; } = null;
public string? CaptureYtDlpWorkingDirectory { get; set; } = null;
public string? CaptureYtDlpCookiesFromBrowser { get; set; } = null;
public string? CaptureYtDlpOutputFormat { get; set; } = null;
public string? CaptureYtDlpParentTerminal { get; set; } = null;
public string? CaptureYtDlpScriptPath { get; set; } = null;
public string? CaptureYtDlpUserAgent { get; set; } = null;
public string? CaptureStreamlinkBinaryPath { get; set; } = null;
public string? CaptureStreamlinkOutputFormat { get; set; } = null;
public string? CaptureStreamlinkRemuxScript { get; set; } = null;
public string? CaptureStreamlinkTwitchOptions { get; set; } = null;
}
public class KickStreamMetaModel : BaseMetaModel
{ {
public required int ChannelId { get; set; } public required int ChannelId { get; set; }
} }
public class PeerTubeMetaModel public class PeerTubeMetaModel : BaseMetaModel
{ {
public required string AccountName { get; set; } public required string AccountName { get; set; }
} }

View File

@@ -920,7 +920,7 @@ public class BotServices
{ {
_logger.Info($"BossmanJack stream event came in. isLive => {isLive}"); _logger.Info($"BossmanJack stream event came in. isLive => {isLive}");
var settings = SettingsProvider.GetMultipleValuesAsync([ var settings = SettingsProvider.GetMultipleValuesAsync([
BuiltIn.Keys.TwitchBossmanJackUsername, BuiltIn.Keys.CaptureEnabled, BuiltIn.Keys.TwitchIcon BuiltIn.Keys.TwitchBossmanJackUsername, BuiltIn.Keys.CaptureEnabled, BuiltIn.Keys.TwitchIcon, BuiltIn.Keys.CaptureStreamlinkBmjWorkingDirectory
]).Result; ]).Result;
var bmjUsername = settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value; var bmjUsername = settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value;
@@ -930,7 +930,12 @@ public class BotServices
if (settings[BuiltIn.Keys.CaptureEnabled].ToBoolean()) if (settings[BuiltIn.Keys.CaptureEnabled].ToBoolean())
{ {
_logger.Info("Capturing Bossman's stream"); _logger.Info("Capturing Bossman's stream");
_ = new StreamCapture($"https://www.twitch.tv/{settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value}", StreamCaptureMethods.Streamlink, _cancellationToken).CaptureAsync(); _ = new StreamCapture($"https://www.twitch.tv/{settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value}",
StreamCaptureMethods.Streamlink,
new CaptureOverridesModel
{
CaptureYtDlpWorkingDirectory = settings[BuiltIn.Keys.CaptureStreamlinkBmjWorkingDirectory].Value
}, _cancellationToken).CaptureAsync();
} }
return; return;
} }
@@ -1025,6 +1030,7 @@ public class BotServices
using var db = new ApplicationDbContext(); using var db = new ApplicationDbContext();
var channels = db.Streams.Where(s => s.Service == StreamService.Kick).Include(s => s.User); var channels = db.Streams.Where(s => s.Service == StreamService.Kick).Include(s => s.User);
StreamDbModel? channel = null; StreamDbModel? channel = null;
KickStreamMetaModel? meta = null;
foreach (var ch in channels) foreach (var ch in channels)
{ {
if (ch.Metadata == null) if (ch.Metadata == null)
@@ -1033,7 +1039,6 @@ public class BotServices
continue; continue;
} }
KickStreamMetaModel meta;
try try
{ {
meta = JsonSerializer.Deserialize<KickStreamMetaModel>(ch.Metadata) ?? meta = JsonSerializer.Deserialize<KickStreamMetaModel>(ch.Metadata) ??
@@ -1071,7 +1076,7 @@ public class BotServices
if (channel.AutoCapture && settings[BuiltIn.Keys.CaptureEnabled].ToBoolean()) if (channel.AutoCapture && settings[BuiltIn.Keys.CaptureEnabled].ToBoolean())
{ {
_logger.Info($"{channel.StreamUrl} is configured to auto capture"); _logger.Info($"{channel.StreamUrl} is configured to auto capture");
_ = new StreamCapture(channel.StreamUrl, StreamCaptureMethods.YtDlp, _cancellationToken).CaptureAsync(); _ = new StreamCapture(channel.StreamUrl, StreamCaptureMethods.YtDlp, meta?.CaptureOverrides, _cancellationToken).CaptureAsync();
} }
} }
@@ -1099,11 +1104,25 @@ public class BotServices
identity = "@" + channel.User.KfUsername; identity = "@" + channel.User.KfUsername;
} }
BaseMetaModel? meta = null;
if (channel.Metadata != null)
{
try
{
meta = JsonSerializer.Deserialize<BaseMetaModel>(channel.Metadata);
}
catch (Exception e)
{
_logger.Error($"Failed to deserialize metadata for Parti stream: {channel.StreamUrl}");
_logger.Error(e);
}
}
_chatBot.SendChatMessage($"{identity} is live! {data.EventTitle} {url}", true); _chatBot.SendChatMessage($"{identity} is live! {data.EventTitle} {url}", true);
if (channel.AutoCapture && settings[BuiltIn.Keys.CaptureEnabled].ToBoolean()) if (channel.AutoCapture && settings[BuiltIn.Keys.CaptureEnabled].ToBoolean())
{ {
_logger.Info($"{channel.StreamUrl} is configured to auto capture"); _logger.Info($"{channel.StreamUrl} is configured to auto capture");
_ = new StreamCapture(url, StreamCaptureMethods.YtDlp, _cancellationToken).CaptureAsync(); _ = new StreamCapture(url, StreamCaptureMethods.YtDlp, meta?.CaptureOverrides, _cancellationToken).CaptureAsync();
} }
} }
@@ -1158,7 +1177,6 @@ public class BotServices
$"{identity} is no longer live! :lossmanjack:", true); $"{identity} is no longer live! :lossmanjack:", true);
} }
// TODO: Fix this so it aligns with the new Persisted Live setting instead of tracking separately
public async Task<bool> CheckBmjIsLive() public async Task<bool> CheckBmjIsLive()
{ {
var isLive = var isLive =

View File

@@ -72,10 +72,24 @@ public class DLive(ChatBot kfChatBot) : IDisposable
await kfChatBot.SendChatMessageAsync($"{identity} is live! {status.Title} {stream.StreamUrl}", true); await kfChatBot.SendChatMessageAsync($"{identity} is live! {status.Title} {stream.StreamUrl}", true);
BaseMetaModel? meta = null;
if (stream.Metadata != null)
{
try
{
meta = JsonSerializer.Deserialize<BaseMetaModel>(stream.Metadata);
}
catch (Exception e)
{
_logger.Error($"Caught an exception when attempting to deserialize metadata for DLive stream {stream.StreamUrl}");
_logger.Error(e);
}
}
if (stream.AutoCapture && settings[BuiltIn.Keys.CaptureEnabled].ToBoolean()) if (stream.AutoCapture && settings[BuiltIn.Keys.CaptureEnabled].ToBoolean())
{ {
_logger.Info($"{stream.StreamUrl} is live and set to auto capture"); _logger.Info($"{stream.StreamUrl} is live and set to auto capture");
_ = new StreamCapture(stream.StreamUrl, StreamCaptureMethods.Streamlink, ct).CaptureAsync(); _ = new StreamCapture(stream.StreamUrl, StreamCaptureMethods.Streamlink, meta?.CaptureOverrides, ct).CaptureAsync();
} }
currentlyLive.Add(username); currentlyLive.Add(username);
} }

View File

@@ -47,7 +47,7 @@ public class Owncast(ChatBot kfChatBot) : IDisposable
if (!status.Online) continue; if (!status.Online) continue;
await kfChatBot.SendChatMessageAsync("https://bossmanjack.tv restream is live!", true); await kfChatBot.SendChatMessageAsync("https://bossmanjack.tv restream is live!", true);
if (!(await SettingsProvider.GetValueAsync(BuiltIn.Keys.CaptureEnabled)).ToBoolean()) continue; if (!(await SettingsProvider.GetValueAsync(BuiltIn.Keys.CaptureEnabled)).ToBoolean()) continue;
_ = new StreamCapture("https://bossmanjack.tv", StreamCaptureMethods.YtDlp, ct).CaptureAsync(); _ = new StreamCapture("https://bossmanjack.tv", StreamCaptureMethods.YtDlp, null, ct).CaptureAsync();
} }
} }

View File

@@ -57,6 +57,7 @@ public class PeerTube(ChatBot kfChatBot) : IDisposable
{ {
if (persistedLive.Contains(stream.Uuid)) continue; if (persistedLive.Contains(stream.Uuid)) continue;
StreamDbModel? dbEntry = null; StreamDbModel? dbEntry = null;
PeerTubeMetaModel? meta = null;
foreach (var row in streams) foreach (var row in streams)
{ {
if (row.Metadata == null) if (row.Metadata == null)
@@ -64,13 +65,16 @@ public class PeerTube(ChatBot kfChatBot) : IDisposable
_logger.Error($"Stream ID {row.Id} has null metadata"); _logger.Error($"Stream ID {row.Id} has null metadata");
continue; continue;
} }
var meta = JsonSerializer.Deserialize<PeerTubeMetaModel>(row.Metadata); meta = JsonSerializer.Deserialize<PeerTubeMetaModel>(row.Metadata);
if (meta == null) if (meta == null)
{ {
_logger.Error($"Caught a null when deserializing the metadata for {row.Id}"); _logger.Error($"Caught a null when deserializing the metadata for {row.Id}");
continue; continue;
} }
if (meta.AccountName == stream.Account.Name) dbEntry = row;
if (meta.AccountName != stream.Account.Name) continue;
dbEntry = row;
break;
} }
if (settings[BuiltIn.Keys.KiwiPeerTubeEnforceWhitelist].ToBoolean() && dbEntry == null) if (settings[BuiltIn.Keys.KiwiPeerTubeEnforceWhitelist].ToBoolean() && dbEntry == null)
{ {
@@ -89,7 +93,7 @@ public class PeerTube(ChatBot kfChatBot) : IDisposable
continue; continue;
} }
_logger.Info($"{stream.Url} is live and set to auto capture (if configured)"); _logger.Info($"{stream.Url} is live and set to auto capture (if configured)");
_ = new StreamCapture(stream.Url, StreamCaptureMethods.YtDlp, ct).CaptureAsync(); _ = new StreamCapture(stream.Url, StreamCaptureMethods.YtDlp, meta?.CaptureOverrides, ct).CaptureAsync();
} }
} }

View File

@@ -1,4 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using KfChatDotNetBot.Models.DbModels;
using KfChatDotNetBot.Settings; using KfChatDotNetBot.Settings;
using NLog; using NLog;
@@ -9,7 +10,7 @@ namespace KfChatDotNetBot.Services;
/// </summary> /// </summary>
/// <param name="streamUrl">Streamer URL</param> /// <param name="streamUrl">Streamer URL</param>
/// <param name="ct">Cancellation token</param> /// <param name="ct">Cancellation token</param>
public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod, CancellationToken ct = default) public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod, CaptureOverridesModel? captureOverrides = null, CancellationToken ct = default)
{ {
private readonly Dictionary<string, Setting> _settings = SettingsProvider private readonly Dictionary<string, Setting> _settings = SettingsProvider
.GetMultipleValuesAsync([BuiltIn.Keys.CaptureYtDlpBinaryPath, BuiltIn.Keys.CaptureYtDlpWorkingDirectory, .GetMultipleValuesAsync([BuiltIn.Keys.CaptureYtDlpBinaryPath, BuiltIn.Keys.CaptureYtDlpWorkingDirectory,
@@ -38,7 +39,7 @@ public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod,
} }
else if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD()) else if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD())
{ {
pStartInfoFileName = _settings[BuiltIn.Keys.CaptureYtDlpParentTerminal].Value!; pStartInfoFileName = captureOverrides?.CaptureYtDlpParentTerminal ?? _settings[BuiltIn.Keys.CaptureYtDlpParentTerminal].Value!;
pStartInfoExecuteArgument = "-x"; pStartInfoExecuteArgument = "-x";
pStartInfoExecuteScript = scriptPath; pStartInfoExecuteScript = scriptPath;
} }
@@ -51,7 +52,7 @@ public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod,
{ {
FileName = pStartInfoFileName, FileName = pStartInfoFileName,
ArgumentList = { pStartInfoExecuteArgument, pStartInfoExecuteScript }, ArgumentList = { pStartInfoExecuteArgument, pStartInfoExecuteScript },
WorkingDirectory = _settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value WorkingDirectory = captureOverrides?.CaptureYtDlpWorkingDirectory ?? _settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value
}); });
if (process == null) if (process == null)
@@ -94,7 +95,7 @@ public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod,
private async Task<string> CreateScriptAsync() private async Task<string> CreateScriptAsync()
{ {
var random = Convert.ToHexString(Guid.NewGuid().ToByteArray()[..4]); var random = Convert.ToHexString(Guid.NewGuid().ToByteArray()[..4]);
var scriptPath = Path.Join(_settings[BuiltIn.Keys.CaptureYtDlpScriptPath].Value, var scriptPath = Path.Join(_settings[captureOverrides?.CaptureYtDlpScriptPath ?? BuiltIn.Keys.CaptureYtDlpScriptPath].Value,
$"bot_ytdlp_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{random}.sh"); $"bot_ytdlp_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}_{random}.sh");
if (OperatingSystem.IsWindows()) if (OperatingSystem.IsWindows())
{ {
@@ -105,9 +106,10 @@ public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod,
string captureLine; string captureLine;
if (captureMethod == StreamCaptureMethods.YtDlp) if (captureMethod == StreamCaptureMethods.YtDlp)
{ {
captureLine = $"{_settings[BuiltIn.Keys.CaptureYtDlpBinaryPath].Value} -o \"{_settings[BuiltIn.Keys.CaptureYtDlpOutputFormat].Value}\" " + captureLine = $"{captureOverrides?.CaptureYtDlpBinaryPath ?? _settings[BuiltIn.Keys.CaptureYtDlpBinaryPath].Value} " +
$"--user-agent \"{_settings[BuiltIn.Keys.CaptureYtDlpUserAgent].Value}\" " + $"-o \"{captureOverrides?.CaptureYtDlpOutputFormat ?? _settings[BuiltIn.Keys.CaptureYtDlpOutputFormat].Value}\" " +
$"--cookies-from-browser {_settings[BuiltIn.Keys.CaptureYtDlpCookiesFromBrowser].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}"; $"--write-info-json --wait-for-video 15 --merge-output-format mp4 --verbose {streamUrl}";
} }
else if (captureMethod == StreamCaptureMethods.Streamlink) else if (captureMethod == StreamCaptureMethods.Streamlink)
@@ -115,9 +117,10 @@ public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod,
var twitchOpts = string.Empty; var twitchOpts = string.Empty;
if (streamUrl.Contains("twitch.tv")) if (streamUrl.Contains("twitch.tv"))
{ {
twitchOpts = _settings[BuiltIn.Keys.CaptureStreamlinkTwitchOptions].Value; twitchOpts = captureOverrides?.CaptureStreamlinkTwitchOptions ?? _settings[BuiltIn.Keys.CaptureStreamlinkTwitchOptions].Value;
} }
captureLine = $"{_settings[BuiltIn.Keys.CaptureStreamlinkBinaryPath].Value} {twitchOpts} --output \"{_settings[BuiltIn.Keys.CaptureStreamlinkOutputFormat].Value}\" " + captureLine = $"{captureOverrides?.CaptureStreamlinkBinaryPath ?? _settings[BuiltIn.Keys.CaptureStreamlinkBinaryPath].Value} {twitchOpts} " +
$"--output \"{captureOverrides?.CaptureStreamlinkOutputFormat ?? _settings[BuiltIn.Keys.CaptureStreamlinkOutputFormat].Value}\" " +
$"--retry-streams 15 --retry-max 10 {streamUrl} best"; $"--retry-streams 15 --retry-max 10 {streamUrl} best";
} }
else else
@@ -129,7 +132,7 @@ public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod,
var remuxLine = string.Empty; var remuxLine = string.Empty;
if (captureMethod == StreamCaptureMethods.Streamlink) if (captureMethod == StreamCaptureMethods.Streamlink)
{ {
remuxLine = _settings[BuiltIn.Keys.CaptureStreamlinkRemuxScript].Value; remuxLine = captureOverrides?.CaptureStreamlinkRemuxScript ?? _settings[BuiltIn.Keys.CaptureStreamlinkRemuxScript].Value;
} }
string scriptContent; string scriptContent;
@@ -138,8 +141,8 @@ public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod,
{ {
// GetPathRoot on Windows returns the top level directory, e.g. "C:\". Assuming the working directory is on another drive such as D: // 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 // 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}" + scriptContent = $"{Path.GetPathRoot(captureOverrides?.CaptureYtDlpWorkingDirectory ?? _settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value)?.TrimEnd('\\')}{Environment.NewLine}" +
$"CD {_settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value}{Environment.NewLine}" + $"CD {captureOverrides?.CaptureYtDlpWorkingDirectory ?? _settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value}{Environment.NewLine}" +
$"{captureLine}{Environment.NewLine}" + $"{captureLine}{Environment.NewLine}" +
$"{remuxLine}{Environment.NewLine}" + $"{remuxLine}{Environment.NewLine}" +
$"PAUSE"; $"PAUSE";
@@ -147,7 +150,7 @@ public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod,
else if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD()) else if (OperatingSystem.IsLinux() || OperatingSystem.IsFreeBSD())
{ {
scriptContent = $"#!/bin/bash{Environment.NewLine}" + scriptContent = $"#!/bin/bash{Environment.NewLine}" +
$"cd {_settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value}{Environment.NewLine}" + $"cd {captureOverrides?.CaptureYtDlpWorkingDirectory ?? _settings[BuiltIn.Keys.CaptureYtDlpWorkingDirectory].Value}{Environment.NewLine}" +
$"{captureLine}{Environment.NewLine}" + $"{captureLine}{Environment.NewLine}" +
$"{remuxLine}{Environment.NewLine}" + $"{remuxLine}{Environment.NewLine}" +
$"read -p \"Press enter to exit\""; $"read -p \"Press enter to exit\"";

View File

@@ -1061,6 +1061,13 @@ public static class BuiltIn
Default = "300", Default = "300",
ValueType = SettingValueType.Text, ValueType = SettingValueType.Text,
Regex = WholeNumberRegex Regex = WholeNumberRegex
},
new BuiltInSettingsModel
{
Key = Keys.CaptureStreamlinkBmjWorkingDirectory,
Description = "Working directory for BMJ's Twitch streams captured with streamlink",
Default = "/root/twitch/",
ValueType = SettingValueType.Text
} }
]; ];
@@ -1183,5 +1190,6 @@ public static class BuiltIn
public static string BotImageChinkSelfDestructDelay = "Bot.Image.ChinkSelfDestructDelay"; public static string BotImageChinkSelfDestructDelay = "Bot.Image.ChinkSelfDestructDelay";
public static string BotRateLimitCooldownAutoDeleteDelay = "Bot.RateLimit.CooldownAutoDeleteDelay"; public static string BotRateLimitCooldownAutoDeleteDelay = "Bot.RateLimit.CooldownAutoDeleteDelay";
public static string BotRateLimitExpiredEntryCleanupInterval = "Bot.RateLimit.ExpiredEntryCleanupInterval"; public static string BotRateLimitExpiredEntryCleanupInterval = "Bot.RateLimit.ExpiredEntryCleanupInterval";
public static string CaptureStreamlinkBmjWorkingDirectory = "Bot.Streamlink.BmjWorkingDirectory";
} }
} }