mirror of
https://github.com/barelyprofessional/KfChatDotNet.git
synced 2026-05-02 04:22:04 -04:00
Migrated streams from bespoke settings to a database table, added DLive support and Streamlink capturing with remux support
This commit is contained in:
@@ -22,4 +22,5 @@ public class ApplicationDbContext : DbContext
|
||||
// public DbSet<PocketWatchAddressDbModel> PocketWatchAddresses { get; set; }
|
||||
// public DbSet<PocketWatchTransactionDbModel> PocketWatchTransactions { get; set; }
|
||||
public DbSet<MomDbModel> Moms { get; set; }
|
||||
public DbSet<StreamDbModel> Streams { get; set; }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using KfChatDotNetBot.Models.DbModels;
|
||||
using KfChatDotNetBot.Settings;
|
||||
using KfChatDotNetWsClient.Models.Events;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace KfChatDotNetBot.Commands;
|
||||
|
||||
@@ -92,33 +93,40 @@ public class NewKickChannelCommand : ICommand
|
||||
{
|
||||
autoCapture = argument.Value == "true";
|
||||
}
|
||||
var channels = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.KickChannels)).JsonDeserialize<List<KickChannelModel>>();
|
||||
var channelId = Convert.ToInt32(arguments["channel_id"].Value);
|
||||
channels ??= [];
|
||||
if (channels.Any(channel => channel.ChannelId == channelId))
|
||||
|
||||
await using var db = new ApplicationDbContext();
|
||||
var url = $"https://kick.com/{arguments["slug"].Value}";
|
||||
if (await db.Streams.AnyAsync(s => s.StreamUrl == url, cancellationToken: ctx))
|
||||
{
|
||||
await botInstance.SendChatMessageAsync("Channel is already in the database", true);
|
||||
return;
|
||||
}
|
||||
|
||||
var forumId = Convert.ToInt32(arguments["forum_id"].Value);
|
||||
channels.Add(new KickChannelModel
|
||||
var forumUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == Convert.ToInt32(arguments["forum_id"].Value), cancellationToken: ctx);
|
||||
|
||||
var meta = JsonConvert.SerializeObject(new KickStreamMetaModel
|
||||
{
|
||||
ChannelId = channelId,
|
||||
ForumId = forumId,
|
||||
ChannelSlug = arguments["slug"].Value,
|
||||
ChannelId = Convert.ToInt32(arguments["channel_id"].Value)
|
||||
});
|
||||
|
||||
db.Streams.Add(new StreamDbModel
|
||||
{
|
||||
Service = StreamService.Kick,
|
||||
User = forumUser,
|
||||
Metadata = meta,
|
||||
StreamUrl = url,
|
||||
AutoCapture = autoCapture
|
||||
});
|
||||
|
||||
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.KickChannels, channels);
|
||||
await db.SaveChangesAsync(ctx);
|
||||
await botInstance.SendChatMessageAsync("Updated list of channels", true);
|
||||
}
|
||||
}
|
||||
|
||||
public class RemoveKickChannelCommand : ICommand
|
||||
public class RemoveStreamChannelCommand : ICommand
|
||||
{
|
||||
public List<Regex> Patterns => [
|
||||
new Regex(@"^admin kick remove (?<channel_id>\d+)$")
|
||||
new Regex(@"^admin stream remove (?<id>\d+)$")
|
||||
];
|
||||
|
||||
public string? HelpText => "Remove a Kick channel from the bot's database";
|
||||
@@ -126,19 +134,17 @@ public class RemoveKickChannelCommand : ICommand
|
||||
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
|
||||
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
|
||||
{
|
||||
var channels = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.KickChannels)).JsonDeserialize<List<KickChannelModel>>();
|
||||
if (channels == null) throw new Exception("Caught a null when deserializing Kick channels");
|
||||
var channelId = Convert.ToInt32(arguments["channel_id"].Value);
|
||||
var channel = channels.FirstOrDefault(ch => ch.ChannelId == channelId);
|
||||
await using var db = new ApplicationDbContext();
|
||||
var rowId = Convert.ToInt32(arguments["id"].Value);
|
||||
var channel = db.Streams.FirstOrDefault(ch => ch.Id == rowId);
|
||||
if (channel == null)
|
||||
{
|
||||
await botInstance.SendChatMessageAsync("Channel is not in the database", true);
|
||||
await botInstance.SendChatMessageAsync("Could not find this row in the database", true);
|
||||
return;
|
||||
}
|
||||
channels.Remove(channel);
|
||||
|
||||
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.KickChannels, channels);
|
||||
await botInstance.SendChatMessageAsync("Updated list of channels", true);
|
||||
db.Streams.Remove(channel);
|
||||
await db.SaveChangesAsync(ctx);
|
||||
await botInstance.SendChatMessageAsync("Updated list of streams", true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,52 +187,74 @@ public class NewPartiChannelCommand : ICommand
|
||||
{
|
||||
autoCapture = argument.Value == "true";
|
||||
}
|
||||
var channels = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.PartiChannels)).JsonDeserialize<List<PartiChannelModel>>();
|
||||
var username = arguments["username"].Value;
|
||||
channels ??= [];
|
||||
if (channels.Any(channel => channel.Username == username))
|
||||
|
||||
await using var db = new ApplicationDbContext();
|
||||
var url = $"https://parti.com/creator/{arguments["social"].Value}/{arguments["username"].Value}/";
|
||||
if (arguments["social"].Value == "discord")
|
||||
{
|
||||
url += "0";
|
||||
}
|
||||
if (await db.Streams.AnyAsync(s => s.StreamUrl == url, cancellationToken: ctx))
|
||||
{
|
||||
await botInstance.SendChatMessageAsync("Channel is already in the database", true);
|
||||
return;
|
||||
}
|
||||
|
||||
var forumId = Convert.ToInt32(arguments["forum_id"].Value);
|
||||
channels.Add(new PartiChannelModel
|
||||
var forumUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == Convert.ToInt32(arguments["forum_id"].Value),
|
||||
cancellationToken: ctx);
|
||||
|
||||
db.Streams.Add(new StreamDbModel
|
||||
{
|
||||
Username = username,
|
||||
ForumId = forumId,
|
||||
AutoCapture = autoCapture,
|
||||
SocialMedia = arguments["social"].Value
|
||||
Service = StreamService.Parti,
|
||||
User = forumUser,
|
||||
StreamUrl = url,
|
||||
AutoCapture = autoCapture
|
||||
});
|
||||
|
||||
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.PartiChannels, channels);
|
||||
await db.SaveChangesAsync(ctx);
|
||||
await botInstance.SendChatMessageAsync("Updated list of channels", true);
|
||||
}
|
||||
}
|
||||
|
||||
public class RemovePartiChannelCommand : ICommand
|
||||
public class NewDLiveChannelCommand : ICommand
|
||||
{
|
||||
public List<Regex> Patterns => [
|
||||
new Regex(@"^admin parti remove (?<username>\S+)$")
|
||||
new Regex(@"^admin dlive add (?<forum_id>\d+) (?<username>\S+) (?<auto_capture>true|false)$"),
|
||||
new Regex(@"^admin dlive add (?<forum_id>\d+) (?<username>\S+)$")
|
||||
|
||||
];
|
||||
|
||||
public string? HelpText => "Remove a Parti channel from the bot's database";
|
||||
public string? HelpText => "Add a DLive channel to the bot's database";
|
||||
public UserRight RequiredRight => UserRight.Admin;
|
||||
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
|
||||
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
|
||||
{
|
||||
var channels = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.PartiChannels)).JsonDeserialize<List<PartiChannelModel>>();
|
||||
if (channels == null) throw new Exception("Caught a null when deserializing Parti channels");
|
||||
var username = arguments["username"].Value;
|
||||
var channel = channels.FirstOrDefault(ch => ch.Username == username);
|
||||
if (channel == null)
|
||||
var autoCapture = false;
|
||||
if (arguments.TryGetValue("auto_capture", out var argument))
|
||||
{
|
||||
await botInstance.SendChatMessageAsync("Channel is not in the database", true);
|
||||
autoCapture = argument.Value == "true";
|
||||
}
|
||||
|
||||
await using var db = new ApplicationDbContext();
|
||||
var url = $"https://dlive.tv/{arguments["username"].Value}";
|
||||
if (await db.Streams.AnyAsync(s => s.StreamUrl == url, cancellationToken: ctx))
|
||||
{
|
||||
await botInstance.SendChatMessageAsync("Channel is already in the database", true);
|
||||
return;
|
||||
}
|
||||
channels.Remove(channel);
|
||||
|
||||
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.PartiChannels, channels);
|
||||
var forumUser = await db.Users.FirstOrDefaultAsync(u => u.KfId == Convert.ToInt32(arguments["forum_id"].Value),
|
||||
cancellationToken: ctx);
|
||||
|
||||
db.Streams.Add(new StreamDbModel
|
||||
{
|
||||
Service = StreamService.DLive,
|
||||
User = forumUser,
|
||||
StreamUrl = url,
|
||||
AutoCapture = autoCapture
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(ctx);
|
||||
await botInstance.SendChatMessageAsync("Updated list of channels", true);
|
||||
}
|
||||
}
|
||||
@@ -292,22 +320,6 @@ public class RemoveCourtHearingCommand : ICommand
|
||||
}
|
||||
}
|
||||
|
||||
public class NonceLiveCommand : ICommand
|
||||
{
|
||||
public List<Regex> Patterns => [
|
||||
new Regex(@"^admin togglenonce$")
|
||||
];
|
||||
|
||||
public string? HelpText => "Toggle IsChrisDjLive";
|
||||
public UserRight RequiredRight => UserRight.TrueAndHonest;
|
||||
public TimeSpan Timeout => TimeSpan.FromSeconds(10);
|
||||
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments, CancellationToken ctx)
|
||||
{
|
||||
botInstance.BotServices.IsChrisDjLive = !botInstance.BotServices.IsChrisDjLive;
|
||||
await botInstance.SendChatMessageAsync($"IsChrisDjLive => {botInstance.BotServices.IsChrisDjLive}", true);
|
||||
}
|
||||
}
|
||||
|
||||
public class DeleteMessagesCommand : ICommand
|
||||
{
|
||||
public List<Regex> Patterns => [
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using KfChatDotNetBot.Models;
|
||||
using KfChatDotNetBot.Models.DbModels;
|
||||
using KfChatDotNetBot.Settings;
|
||||
using KfChatDotNetWsClient.Models.Events;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace KfChatDotNetBot.Commands;
|
||||
|
||||
@@ -73,34 +73,16 @@ public class SelfPromoCommand : ICommand
|
||||
public async Task RunCommand(ChatBot botInstance, MessageModel message, UserDbModel user, GroupCollection arguments,
|
||||
CancellationToken ctx)
|
||||
{
|
||||
var channels = SettingsProvider.GetValueAsync(BuiltIn.Keys.KickChannels).Result.JsonDeserialize<List<KickChannelModel>>();
|
||||
var partiChannels = SettingsProvider.GetValueAsync(BuiltIn.Keys.PartiChannels).Result
|
||||
.JsonDeserialize<List<PartiChannelModel>>();
|
||||
if (channels == null || partiChannels == null)
|
||||
await using var db = new ApplicationDbContext();
|
||||
db.Users.Attach(user);
|
||||
var streams = await db.Streams.Where(s => s.User == user).ToListAsync(ctx);
|
||||
if (streams.Count == 0)
|
||||
{
|
||||
await botInstance.SendChatMessageAsync("For some reason the list of Kick or Parti channels deserialized to null", true);
|
||||
await botInstance.SendChatMessageAsync("You have no streams", true);
|
||||
return;
|
||||
}
|
||||
|
||||
var userChannels = channels.Where(ch => ch.ForumId == user.KfId).ToList();
|
||||
var userPartiChannels = partiChannels.Where(ch => ch.ForumId == user.KfId).ToList();
|
||||
|
||||
if (userChannels.Count == 0 && userPartiChannels.Count == 0)
|
||||
{
|
||||
await botInstance.SendChatMessageAsync("You have no streams.", true);
|
||||
return;
|
||||
}
|
||||
var streamList = userChannels.Aggregate(string.Empty, (current, stream) => current + $"[br]- https://kick.com/{stream.ChannelSlug}");
|
||||
foreach (var stream in userPartiChannels)
|
||||
{
|
||||
var url = $"https://parti.com/creator/{stream.SocialMedia}/{stream.Username}/";
|
||||
if (stream.SocialMedia == "discord")
|
||||
{
|
||||
url += "0";
|
||||
}
|
||||
|
||||
streamList += $"[br]- {url}";
|
||||
}
|
||||
var streamList = streams.Aggregate(string.Empty, (current, stream) => current + $"[br]- {stream.StreamUrl}");
|
||||
|
||||
await botInstance.SendChatMessageAsync(
|
||||
$"@{user.KfUsername} is a weirdo who streams a lot. His channels are at: {streamList}", true);
|
||||
|
||||
396
KfChatDotNetBot/Migrations/20250720061845_Streams.Designer.cs
generated
Normal file
396
KfChatDotNetBot/Migrations/20250720061845_Streams.Designer.cs
generated
Normal file
@@ -0,0 +1,396 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using KfChatDotNetBot;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace KfChatDotNetBot.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20250720061845_Streams")]
|
||||
partial class Streams
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.4");
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.ChipsggBetDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("Amount")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("BetId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Currency")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<float>("CurrencyPrice")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("GameTitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<float>("Multiplier")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateTimeOffset>("Updated")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Win")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("Winnings")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ChipsggBets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.HowlggBetsDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Bet")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BetId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("Date")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Game")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("GameId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Profit")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("HowlggBets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.ImageDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("LastSeen")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Images");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<float>("Amount")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateTimeOffset>("JuicedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Juicers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.MomDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Moms");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.RainbetBetsDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BetId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("BetSeenAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GameName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<float>("Multiplier")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<float>("Payout")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("PublicId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("RainbetUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<float>("Value")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("RainbetBets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.SettingDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("CacheDuration")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<string>("Default")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSecret")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Regex")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ValueType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AutoCapture")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Metadata")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Service")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StreamUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Streams");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TwitchViewCountDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double>("ServerTime")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<DateTimeOffset>("Time")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Viewers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TwitchViewCounts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Ignored")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("KfId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("KfUsername")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("UserRight")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ActivityType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("FirstOccurence")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("LatestOccurence")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UsersWhoWere");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b =>
|
||||
{
|
||||
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.MomDbModel", b =>
|
||||
{
|
||||
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b =>
|
||||
{
|
||||
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", b =>
|
||||
{
|
||||
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
48
KfChatDotNetBot/Migrations/20250720061845_Streams.cs
Normal file
48
KfChatDotNetBot/Migrations/20250720061845_Streams.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace KfChatDotNetBot.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Streams : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Streams",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
UserId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
StreamUrl = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Service = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Metadata = table.Column<string>(type: "TEXT", nullable: true),
|
||||
AutoCapture = table.Column<bool>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Streams", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Streams_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Streams_UserId",
|
||||
table: "Streams",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Streams");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,35 @@ namespace KfChatDotNetBot.Migrations
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AutoCapture")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Metadata")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Service")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("StreamUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("UserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Streams");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TwitchViewCountDbModel", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -339,6 +368,15 @@ namespace KfChatDotNetBot.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b =>
|
||||
{
|
||||
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", b =>
|
||||
{
|
||||
b.HasOne("KfChatDotNetBot.Models.DbModels.UserDbModel", "User")
|
||||
|
||||
8
KfChatDotNetBot/Models/DLiveModels.cs
Normal file
8
KfChatDotNetBot/Models/DLiveModels.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace KfChatDotNetBot.Models;
|
||||
|
||||
public class DLiveIsLiveModel
|
||||
{
|
||||
public required bool IsLive { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public required string Username { get; set; }
|
||||
}
|
||||
38
KfChatDotNetBot/Models/DbModels/StreamDbModel.cs
Normal file
38
KfChatDotNetBot/Models/DbModels/StreamDbModel.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace KfChatDotNetBot.Models.DbModels;
|
||||
|
||||
public class StreamDbModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
/// User associated with the stream if any. If none associated, then it'll just say "Somebody has gone live"
|
||||
/// </summary>
|
||||
public UserDbModel? User { get; set; } = null;
|
||||
/// <summary>
|
||||
/// Absolute URL of the streamer
|
||||
/// </summary>
|
||||
public required string StreamUrl { get; set; }
|
||||
/// <summary>
|
||||
/// Service the streamer is using
|
||||
/// </summary>
|
||||
public required StreamService Service { get; set; }
|
||||
/// <summary>
|
||||
/// JSON containing arbitrary data, e.g. social name for Parti, streamer ID for Kick, etc.
|
||||
/// </summary>
|
||||
public string? Metadata { get; set; } = null;
|
||||
/// <summary>
|
||||
/// Whether to automatically capture a stream when it goes live using yt-dlp / streamlink
|
||||
/// </summary>
|
||||
public bool AutoCapture { get; set; } = false;
|
||||
}
|
||||
|
||||
public enum StreamService
|
||||
{
|
||||
Kick,
|
||||
Parti,
|
||||
DLive
|
||||
}
|
||||
|
||||
public class KickStreamMetaModel
|
||||
{
|
||||
public required int ChannelId { get; set; }
|
||||
}
|
||||
@@ -38,6 +38,8 @@ namespace KfChatDotNetBot
|
||||
await BuiltIn.SyncSettingsWithDb();
|
||||
logger.Info("Migrating settings from config.json (if needed)");
|
||||
await BuiltIn.MigrateJsonSettingsToDb();
|
||||
logger.Info("Migrating streams from settings API to their own table");
|
||||
await BuiltIn.MigrateStreamChannelsToDatabase();
|
||||
logger.Info("Handing over to bot now");
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
new ChatBot();
|
||||
|
||||
@@ -4,6 +4,7 @@ using KfChatDotNetBot.Models;
|
||||
using KfChatDotNetBot.Models.DbModels;
|
||||
using KfChatDotNetBot.Settings;
|
||||
using KickWsClient.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NLog;
|
||||
using Websocket.Client;
|
||||
|
||||
@@ -33,6 +34,7 @@ public class BotServices
|
||||
private Yeet? _yeet;
|
||||
public AlmanacShill? AlmanacShill;
|
||||
private Parti? _parti;
|
||||
private DLive? _dliveStatusCheck;
|
||||
|
||||
private Task? _websocketWatchdog;
|
||||
private Task? _howlggGetUserTimer;
|
||||
@@ -41,7 +43,6 @@ public class BotServices
|
||||
private bool _twitchDisabled;
|
||||
internal bool IsBmjLive;
|
||||
private bool _isBmjLiveSynced;
|
||||
internal bool IsChrisDjLive;
|
||||
private Dictionary<string, SeenYeetBet> _yeetBets = new();
|
||||
|
||||
// lol
|
||||
@@ -81,7 +82,8 @@ public class BotServices
|
||||
BuildBetBolt(),
|
||||
BuildYeet(),
|
||||
BuildRainbet(),
|
||||
BuildParti()
|
||||
BuildParti(),
|
||||
BuildDLiveStatusCheck()
|
||||
];
|
||||
try
|
||||
{
|
||||
@@ -240,9 +242,10 @@ public class BotServices
|
||||
|
||||
private async Task BuildKick()
|
||||
{
|
||||
await using var db = new ApplicationDbContext();
|
||||
var settings = await SettingsProvider.GetMultipleValuesAsync([
|
||||
BuiltIn.Keys.PusherEndpoint, BuiltIn.Keys.Proxy, BuiltIn.Keys.PusherReconnectTimeout,
|
||||
BuiltIn.Keys.KickEnabled, BuiltIn.Keys.KickChannels
|
||||
BuiltIn.Keys.KickEnabled
|
||||
]);
|
||||
KickClient = new KickWsClient.KickWsClient(settings[BuiltIn.Keys.PusherEndpoint].Value!,
|
||||
settings[BuiltIn.Keys.Proxy].Value, settings[BuiltIn.Keys.PusherReconnectTimeout].ToType<int>());
|
||||
@@ -256,11 +259,29 @@ public class BotServices
|
||||
if (settings[BuiltIn.Keys.KickEnabled].ToBoolean())
|
||||
{
|
||||
await KickClient.StartWsClient();
|
||||
var kickChannels = settings[BuiltIn.Keys.KickChannels].JsonDeserialize<List<KickChannelModel>>();
|
||||
if (kickChannels == null) return;
|
||||
var kickChannels = db.Streams.Where(s => s.Service == StreamService.Kick);
|
||||
foreach (var channel in kickChannels)
|
||||
{
|
||||
KickClient.SendPusherSubscribe($"channel.{channel.ChannelId}");
|
||||
if (channel.Metadata == null)
|
||||
{
|
||||
_logger.Error($"Row ID {channel.Id} in the Streams table has null Metadata when it is required for Kick");
|
||||
continue;
|
||||
}
|
||||
|
||||
KickStreamMetaModel meta;
|
||||
try
|
||||
{
|
||||
meta = JsonSerializer.Deserialize<KickStreamMetaModel>(channel.Metadata) ??
|
||||
throw new InvalidOperationException(
|
||||
$"Caught a null when attempting to deserialize metadata for {channel.Id} in the Streams table");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.Error($"Failed to deserialize the metadata for {channel.Id} in the Streams table");
|
||||
_logger.Error(e);
|
||||
continue;
|
||||
}
|
||||
KickClient.SendPusherSubscribe($"channel.{meta.ChannelId}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -295,6 +316,13 @@ public class BotServices
|
||||
_logger.Info("Built the almanac shill task");
|
||||
}
|
||||
|
||||
private async Task BuildDLiveStatusCheck()
|
||||
{
|
||||
_dliveStatusCheck = new DLive(_chatBot);
|
||||
_dliveStatusCheck.StartLiveStatusCheck();
|
||||
_logger.Info("Built the DLive livestream status check task");
|
||||
}
|
||||
|
||||
private async Task BuildParti()
|
||||
{
|
||||
var settings = await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.Proxy, BuiltIn.Keys.PartiEnabled]);
|
||||
@@ -841,16 +869,12 @@ public class BotServices
|
||||
private void OnTwitchStreamStateUpdated(object sender, int channelId, bool isLive)
|
||||
{
|
||||
_logger.Info($"BossmanJack stream event came in. isLive => {isLive}");
|
||||
var settings = SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.RestreamUrl, BuiltIn.Keys.TwitchBossmanJackUsername, BuiltIn.Keys.BotToyStoryImage]).Result;
|
||||
var settings = SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.RestreamUrl, BuiltIn.Keys.TwitchBossmanJackUsername]).Result;
|
||||
|
||||
if (isLive)
|
||||
{
|
||||
_chatBot.SendChatMessage($"{settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value} just went live on Twitch! https://www.twitch.tv/{settings[BuiltIn.Keys.TwitchBossmanJackUsername].Value}\r\n" +
|
||||
settings[BuiltIn.Keys.RestreamUrl].Value);
|
||||
if (IsChrisDjLive)
|
||||
{
|
||||
_chatBot.SendChatMessage($"[img]{settings[BuiltIn.Keys.BotToyStoryImage].Value}[/img]", true);
|
||||
}
|
||||
IsBmjLive = true;
|
||||
return;
|
||||
}
|
||||
@@ -915,11 +939,30 @@ public class BotServices
|
||||
private void OnPusherWsReconnected(object sender, ReconnectionInfo reconnectionInfo)
|
||||
{
|
||||
_logger.Error($"Pusher reconnected due to {reconnectionInfo.Type}");
|
||||
var kickChannels = SettingsProvider.GetValueAsync(BuiltIn.Keys.KickChannels).Result.JsonDeserialize<List<KickChannelModel>>();
|
||||
if (kickChannels == null) return;
|
||||
using var db = new ApplicationDbContext();
|
||||
var kickChannels = db.Streams.Where(s => s.Service == StreamService.Kick);
|
||||
foreach (var channel in kickChannels)
|
||||
{
|
||||
KickClient?.SendPusherSubscribe($"channel.{channel.ChannelId}");
|
||||
if (channel.Metadata == null)
|
||||
{
|
||||
_logger.Error($"Row ID {channel.Id} in the Streams table has null Metadata when it is required for Kick");
|
||||
continue;
|
||||
}
|
||||
|
||||
KickStreamMetaModel meta;
|
||||
try
|
||||
{
|
||||
meta = JsonSerializer.Deserialize<KickStreamMetaModel>(channel.Metadata) ??
|
||||
throw new InvalidOperationException(
|
||||
$"Caught a null when attempting to deserialize metadata for {channel.Id} in the Streams table");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.Error($"Failed to deserialize the metadata for {channel.Id} in the Streams table");
|
||||
_logger.Error(e);
|
||||
continue;
|
||||
}
|
||||
KickClient?.SendPusherSubscribe($"channel.{meta.ChannelId}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -944,113 +987,142 @@ public class BotServices
|
||||
{
|
||||
if (e == null) return;
|
||||
var settings = SettingsProvider.GetMultipleValuesAsync([
|
||||
BuiltIn.Keys.KickChannels, BuiltIn.Keys.BotChrisDjLiveImage, BuiltIn.Keys.CaptureEnabled
|
||||
BuiltIn.Keys.CaptureEnabled
|
||||
]).Result;
|
||||
var channels = settings[BuiltIn.Keys.KickChannels].JsonDeserialize<List<KickChannelModel>>();
|
||||
if (channels == null)
|
||||
using var db = new ApplicationDbContext();
|
||||
var channels = db.Streams.Where(s => s.Service == StreamService.Kick).Include(s => s.User);
|
||||
StreamDbModel? channel = null;
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
_logger.Error("Caught null when grabbing Kick channels");
|
||||
return;
|
||||
if (ch.Metadata == null)
|
||||
{
|
||||
_logger.Error($"Row ID {ch.Id} in the Streams table has null Metadata when it is required for Kick");
|
||||
continue;
|
||||
}
|
||||
|
||||
KickStreamMetaModel meta;
|
||||
try
|
||||
{
|
||||
meta = JsonSerializer.Deserialize<KickStreamMetaModel>(ch.Metadata) ??
|
||||
throw new InvalidOperationException(
|
||||
$"Caught a null when attempting to deserialize metadata for {ch.Id} in the Streams table");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to deserialize the metadata for {ch.Id} in the Streams table");
|
||||
_logger.Error(ex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (meta.ChannelId != e.Livestream.ChannelId) continue;
|
||||
|
||||
channel = ch;
|
||||
break;
|
||||
}
|
||||
|
||||
var channel = channels.FirstOrDefault(ch => ch.ChannelId == e.Livestream.ChannelId);
|
||||
if (channel == null)
|
||||
{
|
||||
_logger.Error($"Caught null when grabbing channel data for {e.Livestream.ChannelId}");
|
||||
_logger.Error($"Failed to find a Kick stream in the database for {e.Livestream.ChannelId} which we got notified is live");
|
||||
_logger.Error("This really should never happen, but could happen if the metadata for a stream gets screwed up at runtime");
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new ApplicationDbContext();
|
||||
var user = db.Users.FirstOrDefault(u => u.KfId == channel.ForumId);
|
||||
if (user == null)
|
||||
var identity = "A streamer";
|
||||
if (channel.User != null)
|
||||
{
|
||||
_logger.Error($"Caught null when retrieving forum user {channel.ForumId}");
|
||||
return;
|
||||
identity = "@" + channel.User.KfUsername;
|
||||
}
|
||||
|
||||
_chatBot.SendChatMessage(
|
||||
$"@{user.KfUsername} is live! {e.Livestream.SessionTitle} https://kick.com/{channel.ChannelSlug}", true);
|
||||
|
||||
if (channel.ChannelSlug == "christopherdj")
|
||||
{
|
||||
IsChrisDjLive = true;
|
||||
_chatBot.SendChatMessage($"[img]{settings[BuiltIn.Keys.BotChrisDjLiveImage].Value}[/img]", true);
|
||||
}
|
||||
$"{identity} is live! {e.Livestream.SessionTitle} {channel.StreamUrl}", 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();
|
||||
_logger.Info($"{channel.StreamUrl} is configured to auto capture");
|
||||
_ = new StreamCapture(channel.StreamUrl, StreamCaptureMethods.YtDlp, _cancellationToken).CaptureAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPartiChannelLiveNotification(object sender, PartiChannelLiveNotificationModel data)
|
||||
{
|
||||
var settings = SettingsProvider
|
||||
.GetMultipleValuesAsync([BuiltIn.Keys.PartiChannels, BuiltIn.Keys.CaptureEnabled]).Result;
|
||||
var channels = settings[BuiltIn.Keys.PartiChannels].JsonDeserialize<List<PartiChannelModel>>();
|
||||
if (channels == null)
|
||||
{
|
||||
_logger.Error("Caught a null when deserializing Parti channels");
|
||||
return;
|
||||
}
|
||||
|
||||
var channel = channels.FirstOrDefault(ch => ch.Username == data.Username);
|
||||
if (channel == null)
|
||||
{
|
||||
_logger.Debug($"Got a Parti live notification for a channel we don't care about: {data.Username}");
|
||||
return;
|
||||
}
|
||||
|
||||
.GetMultipleValuesAsync([BuiltIn.Keys.CaptureEnabled]).Result;
|
||||
using var db = new ApplicationDbContext();
|
||||
var user = db.Users.FirstOrDefault(u => u.KfId == channel.ForumId);
|
||||
if (user == null)
|
||||
{
|
||||
_logger.Error($"Caught a null when retrieving forum ID {channel.ForumId}");
|
||||
return;
|
||||
}
|
||||
|
||||
var url = $"https://parti.com/creator/{data.SocialMedia}/{data.Username}/";
|
||||
if (data.SocialMedia == "discord")
|
||||
{
|
||||
url += "0";
|
||||
}
|
||||
_chatBot.SendChatMessage($"@{user.KfUsername} is live! {data.EventTitle} {url}", true);
|
||||
|
||||
var channel = db.Streams.Include(s => s.User)
|
||||
.FirstOrDefault(s => s.Service == StreamService.Parti && s.StreamUrl == url);
|
||||
if (channel == null)
|
||||
{
|
||||
_logger.Info($"Got a live notification from Parti for a stream we don't care about: {data.SocialMedia}/{data.Username}");
|
||||
return;
|
||||
}
|
||||
var identity = "A streamer";
|
||||
if (channel.User != null)
|
||||
{
|
||||
identity = "@" + channel.User.KfUsername;
|
||||
}
|
||||
|
||||
_chatBot.SendChatMessage($"{identity} is live! {data.EventTitle} {url}", true);
|
||||
if (channel.AutoCapture && settings[BuiltIn.Keys.CaptureEnabled].ToBoolean())
|
||||
{
|
||||
_logger.Info($"{channel.Username} is configured to auto capture");
|
||||
_ = new YtDlpCapture(url, _cancellationToken).CaptureAsync();
|
||||
_logger.Info($"{channel.StreamUrl} is configured to auto capture");
|
||||
_ = new StreamCapture(url, StreamCaptureMethods.YtDlp, _cancellationToken).CaptureAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnStopStreamBroadcast(object sender, KickModels.StopStreamBroadcastEventModel? e)
|
||||
{
|
||||
if (e == null) return;
|
||||
var channels = SettingsProvider.GetValueAsync(BuiltIn.Keys.KickChannels).Result.JsonDeserialize<List<KickChannelModel>>();
|
||||
if (channels == null)
|
||||
using var db = new ApplicationDbContext();
|
||||
var channels = db.Streams.Where(s => s.Service == StreamService.Kick).Include(s => s.User);
|
||||
StreamDbModel? channel = null;
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
_logger.Error("Caught null when grabbing Kick channels");
|
||||
return;
|
||||
if (ch.Metadata == null)
|
||||
{
|
||||
_logger.Error($"Row ID {ch.Id} in the Streams table has null Metadata when it is required for Kick");
|
||||
continue;
|
||||
}
|
||||
|
||||
KickStreamMetaModel meta;
|
||||
try
|
||||
{
|
||||
meta = JsonSerializer.Deserialize<KickStreamMetaModel>(ch.Metadata) ??
|
||||
throw new InvalidOperationException(
|
||||
$"Caught a null when attempting to deserialize metadata for {ch.Id} in the Streams table");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to deserialize the metadata for {ch.Id} in the Streams table");
|
||||
_logger.Error(ex);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (meta.ChannelId != e.Livestream.Id) continue;
|
||||
|
||||
channel = ch;
|
||||
break;
|
||||
}
|
||||
|
||||
var channel = channels.FirstOrDefault(ch => ch.ChannelId == e.Livestream.Channel.Id);
|
||||
if (channel == null)
|
||||
{
|
||||
_logger.Error($"Caught null when grabbing channel data for {e.Livestream.Channel.Id}");
|
||||
_logger.Error($"Failed to find a Kick stream in the database for {e.Livestream.Id} which we got notified is no longer live");
|
||||
_logger.Error("This really should never happen, but could happen if the metadata for a stream gets screwed up at runtime");
|
||||
return;
|
||||
}
|
||||
|
||||
using var db = new ApplicationDbContext();
|
||||
var user = db.Users.FirstOrDefault(u => u.KfId == channel.ForumId);
|
||||
if (user == null)
|
||||
var identity = "A streamer";
|
||||
if (channel.User != null)
|
||||
{
|
||||
_logger.Error($"Caught null when retrieving forum user {channel.ForumId}");
|
||||
return;
|
||||
identity = "@" + channel.User.KfUsername;
|
||||
}
|
||||
|
||||
_chatBot.SendChatMessage(
|
||||
$"@{user.KfUsername} is no longer live! :lossmanjack:", true);
|
||||
if (channel.ChannelSlug == "christopherdj") IsChrisDjLive = false;
|
||||
$"{identity} is no longer live! :lossmanjack:", true);
|
||||
}
|
||||
|
||||
public async Task<bool> CheckBmjIsLive(string bmjUsername)
|
||||
|
||||
150
KfChatDotNetBot/Services/DLive.cs
Normal file
150
KfChatDotNetBot/Services/DLive.cs
Normal file
@@ -0,0 +1,150 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using KfChatDotNetBot.Models;
|
||||
using KfChatDotNetBot.Models.DbModels;
|
||||
using KfChatDotNetBot.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NLog;
|
||||
|
||||
namespace KfChatDotNetBot.Services;
|
||||
|
||||
public class DLive(ChatBot kfChatBot) : IDisposable
|
||||
{
|
||||
private readonly Logger _logger = LogManager.GetCurrentClassLogger();
|
||||
private Task? _liveStatusCheckTask;
|
||||
private CancellationTokenSource _liveStatusCheckTaskCts = new();
|
||||
|
||||
public void StartLiveStatusCheck()
|
||||
{
|
||||
_liveStatusCheckTaskCts = new CancellationTokenSource();
|
||||
_liveStatusCheckTask = Task.Run(LiveStatusCheckTask, _liveStatusCheckTaskCts.Token);
|
||||
}
|
||||
|
||||
private async Task LiveStatusCheckTask()
|
||||
{
|
||||
var interval = (await SettingsProvider.GetValueAsync(BuiltIn.Keys.DLiveCheckInterval)).ToType<int>();
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(interval));
|
||||
while (await timer.WaitForNextTickAsync(_liveStatusCheckTaskCts.Token))
|
||||
{
|
||||
var ct = _liveStatusCheckTaskCts.Token;
|
||||
_logger.Debug("Going to check if anyone is live on DLive now");
|
||||
await using var db = new ApplicationDbContext();
|
||||
var streams = await db.Streams.Where(s => s.Service == StreamService.DLive).Include(s => s.User).ToListAsync(ct);
|
||||
var settings = await SettingsProvider.GetMultipleValuesAsync([
|
||||
BuiltIn.Keys.DLivePersistedCurrentlyLiveStreams, BuiltIn.Keys.CaptureEnabled
|
||||
]);
|
||||
var currentlyLive = settings[BuiltIn.Keys.DLivePersistedCurrentlyLiveStreams].JsonDeserialize<List<string>>() ?? [];
|
||||
foreach (var stream in streams)
|
||||
{
|
||||
var username = stream.StreamUrl.Split('/').LastOrDefault();
|
||||
if (username == null)
|
||||
{
|
||||
_logger.Error($"Could not determine the DLive username from {stream.StreamUrl} in row {stream.Id}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var status = await IsLive(username, ct);
|
||||
if (!status.IsLive)
|
||||
{
|
||||
currentlyLive.Remove(username);
|
||||
continue;
|
||||
}
|
||||
// Already known to be live so do nothing
|
||||
if (currentlyLive.Contains(username)) continue;
|
||||
|
||||
var identity = "A streamer";
|
||||
if (stream.User != null)
|
||||
{
|
||||
identity = "@" + stream.User.KfUsername;
|
||||
}
|
||||
|
||||
await kfChatBot.SendChatMessageAsync($"{identity} is live! {status.Title} {stream.StreamUrl}", true);
|
||||
|
||||
if (stream.AutoCapture && settings[BuiltIn.Keys.CaptureEnabled].ToBoolean())
|
||||
{
|
||||
_logger.Info($"{stream.StreamUrl} is live and set to auto capture");
|
||||
_ = new StreamCapture(stream.StreamUrl, StreamCaptureMethods.Streamlink, ct).CaptureAsync();
|
||||
}
|
||||
currentlyLive.Add(username);
|
||||
}
|
||||
|
||||
_logger.Debug($"Persisting currently live streams, count is {currentlyLive.Count}");
|
||||
await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.DLivePersistedCurrentlyLiveStreams,
|
||||
currentlyLive);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<DLiveIsLiveModel> IsLive(string username, CancellationToken ct = default)
|
||||
{
|
||||
var logger = LogManager.GetCurrentClassLogger();
|
||||
var proxy = await SettingsProvider.GetValueAsync(BuiltIn.Keys.Proxy);
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.All,
|
||||
|
||||
};
|
||||
if (proxy.Value != null)
|
||||
{
|
||||
handler.Proxy = new WebProxy(proxy.Value);
|
||||
handler.UseProxy = true;
|
||||
logger.Debug($"Set proxy for the DLive GraphQL request to {proxy.Value}");
|
||||
}
|
||||
|
||||
var gql = "query { userByDisplayName(displayname:\"" + username + "\") { livestream " +
|
||||
"{ content createdAt title thumbnailUrl watchingCount } username } }";
|
||||
logger.Debug($"Built GraphQL query string: {gql}");
|
||||
var jsonBody = new Dictionary<string, object>
|
||||
{
|
||||
{ "query", gql }
|
||||
};
|
||||
logger.Debug("Created dictionary object for the JSON payload, should serialize to following:");
|
||||
logger.Debug(JsonSerializer.Serialize(jsonBody));
|
||||
using var client = new HttpClient(handler);
|
||||
client.DefaultRequestHeaders.Accept.Clear();
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
var postBody = JsonContent.Create(jsonBody);
|
||||
var response = await client.PostAsync("https://graphigo.prd.dlive.tv/", postBody, ct);
|
||||
var content = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct);
|
||||
logger.Debug("DLive GraphQL endpoint returned the following JSON");
|
||||
logger.Debug(content.GetRawText);
|
||||
// Not live
|
||||
// {"data":{"userByDisplayName":{"livestream":null,"username":"planesfan"}}}
|
||||
// Live
|
||||
// {
|
||||
// "data": {
|
||||
// "userByDisplayName": {
|
||||
// "livestream": {
|
||||
// "content": "",
|
||||
// "createdAt": "1752699050000",
|
||||
// "title": "HUNT FULL ACHAT VENEZ DONNER VOS CALL!!!",
|
||||
// "thumbnailUrl": "https://images.prd.dlivecdn.com/live-thumbnail/a587c2a8-6288-11f0-90fc-d638708e4bb8",
|
||||
// "watchingCount": 799
|
||||
// },
|
||||
// "username": "cashpistache1"
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
var responseData = content.GetProperty("data").GetProperty("userByDisplayName");
|
||||
var isLive = responseData.GetProperty("livestream").ValueKind == JsonValueKind.Object;
|
||||
string? title = null;
|
||||
if (isLive)
|
||||
{
|
||||
title = responseData.GetProperty("livestream").GetProperty("title").GetString();
|
||||
}
|
||||
return new DLiveIsLiveModel
|
||||
{
|
||||
IsLive = isLive,
|
||||
Title = title,
|
||||
Username = responseData.GetProperty("username").GetString() ?? "username was null in GraphQL response"
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_liveStatusCheckTaskCts.Cancel();
|
||||
_liveStatusCheckTask?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,13 @@ namespace KfChatDotNetBot.Services;
|
||||
/// </summary>
|
||||
/// <param name="streamUrl">Streamer URL</param>
|
||||
/// <param name="ct">Cancellation token</param>
|
||||
public class YtDlpCapture(string streamUrl, CancellationToken ct = default)
|
||||
public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod, 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;
|
||||
BuiltIn.Keys.CaptureYtDlpScriptPath, BuiltIn.Keys.CaptureYtDlpUserAgent, BuiltIn.Keys.CaptureStreamlinkBinaryPath,
|
||||
BuiltIn.Keys.CaptureStreamlinkOutputFormat, BuiltIn.Keys.CaptureStreamlinkRemuxScript]).Result;
|
||||
|
||||
private readonly Logger _logger = LogManager.GetCurrentClassLogger();
|
||||
|
||||
@@ -82,6 +83,7 @@ public class YtDlpCapture(string streamUrl, CancellationToken ct = default)
|
||||
_logger.Error("Capture process task faulted");
|
||||
_logger.Error(task.Exception);
|
||||
}
|
||||
_logger.Info($"Script {pStartInfoExecuteScript} launched and yielded to us!");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -99,10 +101,31 @@ public class YtDlpCapture(string streamUrl, CancellationToken ct = default)
|
||||
}
|
||||
_logger.Info($"Generated script path: {scriptPath}");
|
||||
|
||||
var ytDlpLine = $"{_settings[BuiltIn.Keys.CaptureYtDlpBinaryPath].Value} -o \"{_settings[BuiltIn.Keys.CaptureYtDlpOutputFormat].Value}\" " +
|
||||
string captureLine;
|
||||
if (captureMethod == StreamCaptureMethods.YtDlp)
|
||||
{
|
||||
captureLine = $"{_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 --wait-for-video 15 {streamUrl}";
|
||||
}
|
||||
else if (captureMethod == StreamCaptureMethods.Streamlink)
|
||||
{
|
||||
captureLine = $"{_settings[BuiltIn.Keys.CaptureStreamlinkBinaryPath].Value} --output \"{_settings[BuiltIn.Keys.CaptureStreamlinkOutputFormat].Value}\" " +
|
||||
$"--retry-streams 15 --retry-max 10 {streamUrl} best";
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Error($"We were given a straem capture method that doesn't exist: {captureMethod}");
|
||||
throw new UnsupportedStreamCaptureMethodException();
|
||||
}
|
||||
|
||||
var remuxLine = string.Empty;
|
||||
if (captureMethod == StreamCaptureMethods.Streamlink)
|
||||
{
|
||||
remuxLine = _settings[BuiltIn.Keys.CaptureStreamlinkRemuxScript].Value;
|
||||
}
|
||||
|
||||
string scriptContent;
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
@@ -111,14 +134,16 @@ public class YtDlpCapture(string streamUrl, CancellationToken ct = default)
|
||||
// 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}" +
|
||||
$"{captureLine}{Environment.NewLine}" +
|
||||
$"{remuxLine}{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}" +
|
||||
$"{captureLine}{Environment.NewLine}" +
|
||||
$"{remuxLine}{Environment.NewLine}" +
|
||||
$"read -p \"Press enter to exit\"";
|
||||
}
|
||||
else
|
||||
@@ -141,3 +166,11 @@ public class YtDlpCapture(string streamUrl, CancellationToken ct = default)
|
||||
}
|
||||
|
||||
public class UnsupportedOperatingSystemException : Exception;
|
||||
|
||||
public class UnsupportedStreamCaptureMethodException : Exception;
|
||||
|
||||
public enum StreamCaptureMethods
|
||||
{
|
||||
YtDlp,
|
||||
Streamlink
|
||||
}
|
||||
@@ -94,6 +94,55 @@ public static class BuiltIn
|
||||
|
||||
logger.Info("File renamed");
|
||||
}
|
||||
#pragma warning disable CS0612 // Type or member is obsolete
|
||||
public static async Task MigrateStreamChannelsToDatabase()
|
||||
{
|
||||
var logger = LogManager.GetCurrentClassLogger();
|
||||
await using var db = new ApplicationDbContext();
|
||||
if (await db.Streams.AnyAsync())
|
||||
{
|
||||
logger.Info("Streams already migrated as there are rows in the table");
|
||||
return;
|
||||
}
|
||||
var channels =
|
||||
await SettingsProvider.GetMultipleValuesAsync([Keys.KickChannels, Keys.PartiChannels]);
|
||||
var kickChannels = channels[Keys.KickChannels].JsonDeserialize<List<KickChannelModel>>();
|
||||
foreach (var channel in kickChannels ?? [])
|
||||
{
|
||||
var user = await db.Users.FirstAsync(u => u.KfId == channel.ForumId);
|
||||
await db.Streams.AddAsync(new StreamDbModel
|
||||
{
|
||||
StreamUrl = $"https://kick.com/{channel.ChannelSlug}",
|
||||
User = user,
|
||||
AutoCapture = channel.AutoCapture,
|
||||
Metadata = JsonSerializer.Serialize(new KickStreamMetaModel { ChannelId = channel.ChannelId } ),
|
||||
Service = StreamService.Kick
|
||||
});
|
||||
logger.Info($"Migrated {channel.ChannelSlug} Kick channel");
|
||||
}
|
||||
|
||||
var partiChannels = channels[Keys.PartiChannels].JsonDeserialize<List<PartiChannelModel>>();
|
||||
#pragma warning restore CS0612 // Type or member is obsolete
|
||||
foreach (var channel in partiChannels ?? [])
|
||||
{
|
||||
var user = await db.Users.FirstAsync(u => u.KfId == channel.ForumId);
|
||||
var streamUrl = $"https://parti.com/creator/{channel.SocialMedia}/{channel.Username}/";
|
||||
if (channel.SocialMedia == "discord")
|
||||
{
|
||||
streamUrl += "0";
|
||||
}
|
||||
await db.Streams.AddAsync(new StreamDbModel
|
||||
{
|
||||
StreamUrl = streamUrl,
|
||||
AutoCapture = channel.AutoCapture,
|
||||
Service = StreamService.Parti,
|
||||
User = user
|
||||
});
|
||||
logger.Info($"Migrated {channel.Username} Parti channel");
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static void SafelyRenameFile(string oldName, string newName)
|
||||
{
|
||||
@@ -458,7 +507,9 @@ public static class BuiltIn
|
||||
},
|
||||
new BuiltInSettingsModel
|
||||
{
|
||||
#pragma warning disable CS0612 // Type or member is obsolete
|
||||
Key = Keys.KickChannels,
|
||||
#pragma warning restore CS0612 // Type or member is obsolete
|
||||
Description = "Kick channels the bot knows about for notifications",
|
||||
Default = "[]",
|
||||
ValueType = SettingValueType.Array
|
||||
@@ -787,10 +838,48 @@ public static class BuiltIn
|
||||
},
|
||||
new BuiltInSettingsModel
|
||||
{
|
||||
#pragma warning disable CS0612 // Type or member is obsolete
|
||||
Key = Keys.PartiChannels,
|
||||
#pragma warning restore CS0612 // Type or member is obsolete
|
||||
Description = "JSON of all the Parti channels to listen to",
|
||||
Default = "[]",
|
||||
ValueType = SettingValueType.Complex
|
||||
},
|
||||
new BuiltInSettingsModel
|
||||
{
|
||||
Key = Keys.CaptureStreamlinkOutputFormat,
|
||||
Description = "Output format to pass to streamlink using --output",
|
||||
Default = "%(title)s - %(uploader)s [%(id)s] %(upload_date)s %(timestamp)s.ts",
|
||||
ValueType = SettingValueType.Text
|
||||
},
|
||||
new BuiltInSettingsModel
|
||||
{
|
||||
Key = Keys.CaptureStreamlinkBinaryPath,
|
||||
Description = "Path of the streamlink binary",
|
||||
Default = "/usr/local/bin/streamlink",
|
||||
ValueType = SettingValueType.Text
|
||||
},
|
||||
new BuiltInSettingsModel
|
||||
{
|
||||
Key = Keys.CaptureStreamlinkRemuxScript,
|
||||
Description = "Path of the remux script to convert .ts to .mp4",
|
||||
Default = "/root/BMJ/Convert-TsToMp4.ps1",
|
||||
ValueType = SettingValueType.Text
|
||||
},
|
||||
new BuiltInSettingsModel
|
||||
{
|
||||
Key = Keys.DLiveCheckInterval,
|
||||
Description = "How often (in seconds) to check if a DLive streamer is live",
|
||||
Default = "15",
|
||||
Regex = @"\d+",
|
||||
ValueType = SettingValueType.Text
|
||||
},
|
||||
new BuiltInSettingsModel
|
||||
{
|
||||
Key = Keys.DLivePersistedCurrentlyLiveStreams,
|
||||
Description = "Array of DLive streamers who are currently live for persistence between bot restarts",
|
||||
Default = "[]",
|
||||
ValueType = SettingValueType.Complex
|
||||
}
|
||||
];
|
||||
|
||||
@@ -838,6 +927,7 @@ public static class BuiltIn
|
||||
public static string CrackedZalgoFuckUpPosition = "Cracked.ZalgoFuckUpPosition";
|
||||
public static string BotDisconnectReplayLimit = "Bot.DisconnectReplayLimit";
|
||||
public static string KiwiFarmsJoinFailLimit = "KiwiFarms.JoinFailLimit";
|
||||
[Obsolete]
|
||||
public static string KickChannels = "Kick.Channels";
|
||||
public static string BotCleanStartTime = "Bot.Clean.StartTime";
|
||||
public static string BotRehabEndTime = "Bot.Rehab.EndTime";
|
||||
@@ -882,6 +972,12 @@ public static class BuiltIn
|
||||
public static string CaptureYtDlpParentTerminal = "Capture.YtDlp.ParentTerminal";
|
||||
public static string CaptureYtDlpScriptPath = "Capture.YtDlp.ScriptPath";
|
||||
public static string PartiEnabled = "Parti.Enabled";
|
||||
[Obsolete]
|
||||
public static string PartiChannels = "Parti.Channels";
|
||||
public static string CaptureStreamlinkBinaryPath = "Capture.Streamlink.BinaryPath";
|
||||
public static string CaptureStreamlinkOutputFormat = "Capture.Streamlink.OutputFormat";
|
||||
public static string CaptureStreamlinkRemuxScript = "Capture.Streamlink.RemuxScript";
|
||||
public static string DLiveCheckInterval = "DLive.CheckInterval";
|
||||
public static string DLivePersistedCurrentlyLiveStreams = "DLive.PersistedCurrentlyLiveStreams";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user