diff --git a/KfChatDotNetBot/Models/DbModels/StreamDbModel.cs b/KfChatDotNetBot/Models/DbModels/StreamDbModel.cs index 7b73819..2273436 100644 --- a/KfChatDotNetBot/Models/DbModels/StreamDbModel.cs +++ b/KfChatDotNetBot/Models/DbModels/StreamDbModel.cs @@ -29,10 +29,16 @@ public enum StreamService { Kick, Parti, - DLive + DLive, + KiwiPeerTube } public class KickStreamMetaModel { public required int ChannelId { get; set; } +} + +public class PeerTubeMetaModel +{ + public required string AccountName { get; set; } } \ No newline at end of file diff --git a/KfChatDotNetBot/Models/PeerTubeModels.cs b/KfChatDotNetBot/Models/PeerTubeModels.cs new file mode 100644 index 0000000..582e104 --- /dev/null +++ b/KfChatDotNetBot/Models/PeerTubeModels.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Serialization; + +namespace KfChatDotNetBot.Models; + +public class PeerTubeVideoDataModel +{ + [JsonPropertyName("uuid")] + public required string Uuid { get; set; } + [JsonPropertyName("shortUUID")] + public required string ShortUuid { get; set; } + [JsonPropertyName("url")] + public required string Url { get; set; } + [JsonPropertyName("name")] + public required string Name { get; set; } + [JsonPropertyName("category")] + public Dictionary? Category { get; set; } + [JsonPropertyName("isLive")] + public required bool IsLive { get; set; } + [JsonPropertyName("account")] + public required PeerTubeAccountOrChannelModel Account { get; set; } + [JsonPropertyName("channel")] + public required PeerTubeAccountOrChannelModel Channel { get; set; } +} + +public class PeerTubeAccountOrChannelModel +{ + [JsonPropertyName("displayName")] + public required string DisplayName { get; set; } + [JsonPropertyName("name")] + public required string Name { get; set; } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Services/BotServices.cs b/KfChatDotNetBot/Services/BotServices.cs index 11a003a..39e8fc0 100644 --- a/KfChatDotNetBot/Services/BotServices.cs +++ b/KfChatDotNetBot/Services/BotServices.cs @@ -35,6 +35,7 @@ public class BotServices public AlmanacShill? AlmanacShill; private Parti? _parti; private DLive? _dliveStatusCheck; + private PeerTube? _peerTubeStatusCheck; private Task? _websocketWatchdog; private Task? _howlggGetUserTimer; @@ -83,7 +84,8 @@ public class BotServices BuildYeet(), BuildRainbet(), BuildParti(), - BuildDLiveStatusCheck() + BuildDLiveStatusCheck(), + BuildPeerTubeLiveStatusCheck() ]; try { @@ -324,6 +326,14 @@ public class BotServices return Task.CompletedTask; } + private Task BuildPeerTubeLiveStatusCheck() + { + _peerTubeStatusCheck = new PeerTube(_chatBot); + _peerTubeStatusCheck.StartLiveStatusCheck(); + _logger.Info("Built the PeerTube livestream status check task"); + return Task.CompletedTask; + } + private async Task BuildParti() { var settings = await SettingsProvider.GetMultipleValuesAsync([BuiltIn.Keys.Proxy, BuiltIn.Keys.PartiEnabled]); diff --git a/KfChatDotNetBot/Services/PeerTube.cs b/KfChatDotNetBot/Services/PeerTube.cs new file mode 100644 index 0000000..6b4fc1b --- /dev/null +++ b/KfChatDotNetBot/Services/PeerTube.cs @@ -0,0 +1,141 @@ +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 PeerTube(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.KiwiPeerTubeCheckInterval)).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 PeerTube now"); + await using var db = new ApplicationDbContext(); + var streams = await db.Streams.Where(s => s.Service == StreamService.KiwiPeerTube).Include(s => s.User).ToListAsync(ct); + var settings = await SettingsProvider.GetMultipleValuesAsync([ + BuiltIn.Keys.KiwiPeerTubePersistedCurrentlyLiveStreams, BuiltIn.Keys.CaptureEnabled, BuiltIn.Keys.KiwiPeerTubeEnabled, + BuiltIn.Keys.KiwiPeerTubeEnforceWhitelist + ]); + if (!settings[BuiltIn.Keys.KiwiPeerTubeEnabled].ToBoolean()) + { + _logger.Debug("PeerTube disabled"); + continue; + } + var persistedLive = settings[BuiltIn.Keys.KiwiPeerTubePersistedCurrentlyLiveStreams].JsonDeserialize>() ?? []; + List currentlyLive; + try + { + currentlyLive = await GetLiveStreams(ct); + } + catch (Exception e) + { + _logger.Error("Caught an error while trying to get the currently live streams from Kiwi PeerTube"); + _logger.Error(e); + continue; + } + foreach (var stream in currentlyLive) + { + if (persistedLive.Contains(stream.Uuid)) continue; + StreamDbModel? dbEntry = null; + foreach (var row in streams) + { + if (row.Metadata == null) + { + _logger.Error($"Stream ID {row.Id} has null metadata"); + continue; + } + var meta = JsonSerializer.Deserialize(row.Metadata); + if (meta == null) + { + _logger.Error($"Caught a null when deserializing the metadata for {row.Id}"); + continue; + } + if (meta.AccountName == stream.Account.Name) dbEntry = row; + } + if (settings[BuiltIn.Keys.KiwiPeerTubeEnforceWhitelist].ToBoolean() && dbEntry == null) + { + _logger.Info($"{stream.Account.Name} is live but whitelisting is enforced and the username isn't in the stream database"); + continue; + } + + persistedLive.Add(stream.Uuid); + await kfChatBot.SendChatMessageAsync($"{stream.Account.DisplayName} is live! {stream.Name} {stream.Url}", true); + + if (settings[BuiltIn.Keys.CaptureEnabled].ToBoolean()) + { + if (dbEntry != null && !dbEntry.AutoCapture) + { + _logger.Info($"{stream.Url} is live but auto capture is disabled for this stream"); + continue; + } + _logger.Info($"{stream.Url} is live and set to auto capture (if configured)"); + _ = new StreamCapture(stream.Url, StreamCaptureMethods.YtDlp, ct).CaptureAsync(); + } + } + + // The ToList will create a copy of the list so we can work on the original one + foreach (var persisted in persistedLive.ToList()) + { + var stream = currentlyLive.FirstOrDefault(l => l.Uuid == persisted); + if (stream == null) persistedLive.Remove(persisted); + } + + _logger.Debug($"Persisting currently live streams, count is {currentlyLive.Count}"); + await SettingsProvider.SetValueAsJsonObjectAsync(BuiltIn.Keys.KiwiPeerTubePersistedCurrentlyLiveStreams, + currentlyLive); + } + } + + public static async Task> GetLiveStreams(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 PeerTube API request to {proxy.Value}"); + } + + using var client = new HttpClient(handler); + client.DefaultRequestHeaders.Accept.Clear(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + var response = await client.GetAsync("https://kiwifarms.tv/api/v1/videos?start=0&count=25&sort=-publishedAt&skipCount=true&nsfw=both&nsfwFlagsExcluded=0&nsfwFlagsIncluded=0&isLive=true", ct); + var content = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + logger.Debug("PeerTube endpoint returned the following JSON"); + logger.Debug(content.GetRawText); + return content.GetProperty("data").Deserialize>() ?? throw new InvalidOperationException(); + } + + public void Dispose() + { + _liveStatusCheckTaskCts.Cancel(); + _liveStatusCheckTask?.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file diff --git a/KfChatDotNetBot/Settings/BuiltIn.cs b/KfChatDotNetBot/Settings/BuiltIn.cs index c4a9435..ce2aab3 100644 --- a/KfChatDotNetBot/Settings/BuiltIn.cs +++ b/KfChatDotNetBot/Settings/BuiltIn.cs @@ -880,6 +880,37 @@ public static class BuiltIn Description = "Array of DLive streamers who are currently live for persistence between bot restarts", Default = "[]", ValueType = SettingValueType.Complex + }, + new BuiltInSettingsModel + { + Key = Keys.KiwiPeerTubePersistedCurrentlyLiveStreams, + Description = "Array of Kiwi PeerTube stream GUIDs which are currently live for persistence between bot restarts", + Default = "[]", + ValueType = SettingValueType.Complex + }, + new BuiltInSettingsModel + { + Key = Keys.KiwiPeerTubeEnabled, + Description = "Whether the Kiwi PeerTube live notification is enabled", + Default = "true", + ValueType = SettingValueType.Boolean, + Regex = "(true|false)" + }, + new BuiltInSettingsModel + { + Key = Keys.KiwiPeerTubeCheckInterval, + Description = "Interval (in seconds) to check live streams", + Default = "10", + ValueType = SettingValueType.Text, + Regex = @"\d+" + }, + new BuiltInSettingsModel + { + Key = Keys.KiwiPeerTubeEnforceWhitelist, + Description = "Whether to enforce the use of a whitelist (i.e. streamer must be in the database)", + Default = "false", + ValueType = SettingValueType.Boolean, + Regex = "(true|false)" } ]; @@ -979,5 +1010,9 @@ public static class BuiltIn public static string CaptureStreamlinkRemuxScript = "Capture.Streamlink.RemuxScript"; public static string DLiveCheckInterval = "DLive.CheckInterval"; public static string DLivePersistedCurrentlyLiveStreams = "DLive.PersistedCurrentlyLiveStreams"; + public static string KiwiPeerTubePersistedCurrentlyLiveStreams = "KiwiPeerTube.PersistedCurrentlyLiveStreams"; + public static string KiwiPeerTubeEnabled = "KiwiPeerTube.Enabled"; + public static string KiwiPeerTubeCheckInterval = "KiwiPeerTube.CheckInterval"; + public static string KiwiPeerTubeEnforceWhitelist = "KiwiPeerTube.EnforceWhitelist"; } } \ No newline at end of file