From c134a6808d95822f5b1fd6cbd924e8d3a04a88cf Mon Sep 17 00:00:00 2001 From: barelyprofessional <150058423+barelyprofessional@users.noreply.github.com> Date: Sun, 20 Jul 2025 01:27:00 -0500 Subject: [PATCH] Migrated streams from bespoke settings to a database table, added DLive support and Streamlink capturing with remux support --- KfChatDotNetBot/ApplicationDbContext.cs | 1 + KfChatDotNetBot/Commands/AdminCommands.cs | 136 +++--- KfChatDotNetBot/Commands/RestreamCommands.cs | 32 +- .../20250720061845_Streams.Designer.cs | 396 ++++++++++++++++++ .../Migrations/20250720061845_Streams.cs | 48 +++ .../ApplicationDbContextModelSnapshot.cs | 38 ++ KfChatDotNetBot/Models/DLiveModels.cs | 8 + .../Models/DbModels/StreamDbModel.cs | 38 ++ KfChatDotNetBot/Program.cs | 2 + KfChatDotNetBot/Services/BotServices.cs | 220 ++++++---- KfChatDotNetBot/Services/DLive.cs | 150 +++++++ .../{YtDlpCapture.cs => StreamCapture.cs} | 51 ++- KfChatDotNetBot/Settings/BuiltIn.cs | 96 +++++ 13 files changed, 1046 insertions(+), 170 deletions(-) create mode 100644 KfChatDotNetBot/Migrations/20250720061845_Streams.Designer.cs create mode 100644 KfChatDotNetBot/Migrations/20250720061845_Streams.cs create mode 100644 KfChatDotNetBot/Models/DLiveModels.cs create mode 100644 KfChatDotNetBot/Models/DbModels/StreamDbModel.cs create mode 100644 KfChatDotNetBot/Services/DLive.cs rename KfChatDotNetBot/Services/{YtDlpCapture.cs => StreamCapture.cs} (73%) diff --git a/KfChatDotNetBot/ApplicationDbContext.cs b/KfChatDotNetBot/ApplicationDbContext.cs index 2610418..f3047fd 100644 --- a/KfChatDotNetBot/ApplicationDbContext.cs +++ b/KfChatDotNetBot/ApplicationDbContext.cs @@ -22,4 +22,5 @@ public class ApplicationDbContext : DbContext // public DbSet PocketWatchAddresses { get; set; } // public DbSet PocketWatchTransactions { get; set; } public DbSet Moms { get; set; } + public DbSet Streams { get; set; } } \ No newline at end of file diff --git a/KfChatDotNetBot/Commands/AdminCommands.cs b/KfChatDotNetBot/Commands/AdminCommands.cs index ae6da55..75fff84 100644 --- a/KfChatDotNetBot/Commands/AdminCommands.cs +++ b/KfChatDotNetBot/Commands/AdminCommands.cs @@ -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>(); - 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 Patterns => [ - new Regex(@"^admin kick remove (?\d+)$") + new Regex(@"^admin stream remove (?\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>(); - 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>(); - 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 Patterns => [ - new Regex(@"^admin parti remove (?\S+)$") + new Regex(@"^admin dlive add (?\d+) (?\S+) (?true|false)$"), + new Regex(@"^admin dlive add (?\d+) (?\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>(); - 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 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 Patterns => [ diff --git a/KfChatDotNetBot/Commands/RestreamCommands.cs b/KfChatDotNetBot/Commands/RestreamCommands.cs index 2f06940..07b5c5a 100644 --- a/KfChatDotNetBot/Commands/RestreamCommands.cs +++ b/KfChatDotNetBot/Commands/RestreamCommands.cs @@ -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>(); - var partiChannels = SettingsProvider.GetValueAsync(BuiltIn.Keys.PartiChannels).Result - .JsonDeserialize>(); - 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); diff --git a/KfChatDotNetBot/Migrations/20250720061845_Streams.Designer.cs b/KfChatDotNetBot/Migrations/20250720061845_Streams.Designer.cs new file mode 100644 index 0000000..f241abd --- /dev/null +++ b/KfChatDotNetBot/Migrations/20250720061845_Streams.Designer.cs @@ -0,0 +1,396 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("REAL"); + + b.Property("BetId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CurrencyPrice") + .HasColumnType("REAL"); + + b.Property("GameTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Updated") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Win") + .HasColumnType("INTEGER"); + + b.Property("Winnings") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.ToTable("ChipsggBets"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.HowlggBetsDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bet") + .HasColumnType("INTEGER"); + + b.Property("BetId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Game") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("INTEGER"); + + b.Property("Profit") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("HowlggBets"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.ImageDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSeen") + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.JuicerDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("REAL"); + + b.Property("JuicedAt") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Juicers"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.MomDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Moms"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.RainbetBetsDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BetId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("BetSeenAt") + .HasColumnType("TEXT"); + + b.Property("GameName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Multiplier") + .HasColumnType("REAL"); + + b.Property("Payout") + .HasColumnType("REAL"); + + b.Property("PublicId") + .HasColumnType("TEXT"); + + b.Property("RainbetUserId") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.ToTable("RainbetBets"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.SettingDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CacheDuration") + .HasColumnType("REAL"); + + b.Property("Default") + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsSecret") + .HasColumnType("INTEGER"); + + b.Property("Key") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Regex") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.Property("ValueType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoCapture") + .HasColumnType("INTEGER"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("INTEGER"); + + b.Property("StreamUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Streams"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TwitchViewCountDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ServerTime") + .HasColumnType("REAL"); + + b.Property("Time") + .HasColumnType("TEXT"); + + b.Property("Topic") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Viewers") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("TwitchViewCounts"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("KfId") + .HasColumnType("INTEGER"); + + b.Property("KfUsername") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserRight") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.UserWhoWasDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ActivityType") + .HasColumnType("INTEGER"); + + b.Property("FirstOccurence") + .HasColumnType("TEXT"); + + b.Property("LatestOccurence") + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/KfChatDotNetBot/Migrations/20250720061845_Streams.cs b/KfChatDotNetBot/Migrations/20250720061845_Streams.cs new file mode 100644 index 0000000..98358a7 --- /dev/null +++ b/KfChatDotNetBot/Migrations/20250720061845_Streams.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace KfChatDotNetBot.Migrations +{ + /// + public partial class Streams : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Streams", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "INTEGER", nullable: true), + StreamUrl = table.Column(type: "TEXT", nullable: false), + Service = table.Column(type: "INTEGER", nullable: false), + Metadata = table.Column(type: "TEXT", nullable: true), + AutoCapture = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Streams"); + } + } +} diff --git a/KfChatDotNetBot/Migrations/ApplicationDbContextModelSnapshot.cs b/KfChatDotNetBot/Migrations/ApplicationDbContextModelSnapshot.cs index 4fca39a..67897fc 100644 --- a/KfChatDotNetBot/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/KfChatDotNetBot/Migrations/ApplicationDbContextModelSnapshot.cs @@ -244,6 +244,35 @@ namespace KfChatDotNetBot.Migrations b.ToTable("Settings"); }); + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.StreamDbModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoCapture") + .HasColumnType("INTEGER"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Service") + .HasColumnType("INTEGER"); + + b.Property("StreamUrl") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Streams"); + }); + modelBuilder.Entity("KfChatDotNetBot.Models.DbModels.TwitchViewCountDbModel", b => { b.Property("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") diff --git a/KfChatDotNetBot/Models/DLiveModels.cs b/KfChatDotNetBot/Models/DLiveModels.cs new file mode 100644 index 0000000..95b104a --- /dev/null +++ b/KfChatDotNetBot/Models/DLiveModels.cs @@ -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; } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Models/DbModels/StreamDbModel.cs b/KfChatDotNetBot/Models/DbModels/StreamDbModel.cs new file mode 100644 index 0000000..7b73819 --- /dev/null +++ b/KfChatDotNetBot/Models/DbModels/StreamDbModel.cs @@ -0,0 +1,38 @@ +namespace KfChatDotNetBot.Models.DbModels; + +public class StreamDbModel +{ + public int Id { get; set; } + /// + /// User associated with the stream if any. If none associated, then it'll just say "Somebody has gone live" + /// + public UserDbModel? User { get; set; } = null; + /// + /// Absolute URL of the streamer + /// + public required string StreamUrl { get; set; } + /// + /// Service the streamer is using + /// + public required StreamService Service { get; set; } + /// + /// JSON containing arbitrary data, e.g. social name for Parti, streamer ID for Kick, etc. + /// + public string? Metadata { get; set; } = null; + /// + /// Whether to automatically capture a stream when it goes live using yt-dlp / streamlink + /// + public bool AutoCapture { get; set; } = false; +} + +public enum StreamService +{ + Kick, + Parti, + DLive +} + +public class KickStreamMetaModel +{ + public required int ChannelId { get; set; } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Program.cs b/KfChatDotNetBot/Program.cs index 3611daa..a19dc35 100644 --- a/KfChatDotNetBot/Program.cs +++ b/KfChatDotNetBot/Program.cs @@ -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(); diff --git a/KfChatDotNetBot/Services/BotServices.cs b/KfChatDotNetBot/Services/BotServices.cs index e9686cb..7e900e3 100644 --- a/KfChatDotNetBot/Services/BotServices.cs +++ b/KfChatDotNetBot/Services/BotServices.cs @@ -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 _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()); @@ -256,11 +259,29 @@ public class BotServices if (settings[BuiltIn.Keys.KickEnabled].ToBoolean()) { await KickClient.StartWsClient(); - var kickChannels = settings[BuiltIn.Keys.KickChannels].JsonDeserialize>(); - 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(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}"); } } } @@ -294,6 +315,13 @@ public class BotServices AlmanacShill.StartShillTask(); _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() { @@ -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>(); - 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(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>(); - 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(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>(); - 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>(); - 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(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 CheckBmjIsLive(string bmjUsername) diff --git a/KfChatDotNetBot/Services/DLive.cs b/KfChatDotNetBot/Services/DLive.cs new file mode 100644 index 0000000..196d8b9 --- /dev/null +++ b/KfChatDotNetBot/Services/DLive.cs @@ -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(); + 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>() ?? []; + 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 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 + { + { "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(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); + } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Services/YtDlpCapture.cs b/KfChatDotNetBot/Services/StreamCapture.cs similarity index 73% rename from KfChatDotNetBot/Services/YtDlpCapture.cs rename to KfChatDotNetBot/Services/StreamCapture.cs index e00d52e..d6eb804 100644 --- a/KfChatDotNetBot/Services/YtDlpCapture.cs +++ b/KfChatDotNetBot/Services/StreamCapture.cs @@ -9,12 +9,13 @@ namespace KfChatDotNetBot.Services; /// /// Streamer URL /// Cancellation token -public class YtDlpCapture(string streamUrl, CancellationToken ct = default) +public class StreamCapture(string streamUrl, StreamCaptureMethods captureMethod, CancellationToken ct = default) { private readonly Dictionary _settings = SettingsProvider .GetMultipleValuesAsync([BuiltIn.Keys.CaptureYtDlpBinaryPath, BuiltIn.Keys.CaptureYtDlpWorkingDirectory, BuiltIn.Keys.CaptureYtDlpCookiesFromBrowser, BuiltIn.Keys.CaptureYtDlpOutputFormat, BuiltIn.Keys.CaptureYtDlpParentTerminal, - BuiltIn.Keys.CaptureYtDlpScriptPath, BuiltIn.Keys.CaptureYtDlpUserAgent]).Result; + 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!"); } /// @@ -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}\" " + - $"--user-agent \"{_settings[BuiltIn.Keys.CaptureYtDlpUserAgent].Value}\" " + - $"--cookies-from-browser {_settings[BuiltIn.Keys.CaptureYtDlpCookiesFromBrowser].Value} " + - $"--write-info-json --wait-for-video 15 {streamUrl}"; + 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 @@ -140,4 +165,12 @@ public class YtDlpCapture(string streamUrl, CancellationToken ct = default) } } -public class UnsupportedOperatingSystemException : Exception; \ No newline at end of file +public class UnsupportedOperatingSystemException : Exception; + +public class UnsupportedStreamCaptureMethodException : Exception; + +public enum StreamCaptureMethods +{ + YtDlp, + Streamlink +} \ No newline at end of file diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs index 32b58a9..b0587a7 100644 --- a/KfChatDotNetBot/Settings/BuiltIn.cs +++ b/KfChatDotNetBot/Settings/BuiltIn.cs @@ -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>(); + 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>(); +#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"; } } \ No newline at end of file