More restructuring

This commit is contained in:
Kwoth
2023-03-26 14:44:25 +02:00
parent 01f70f0a24
commit 308ba36b2e
319 changed files with 681 additions and 218 deletions

View File

@@ -0,0 +1,64 @@
#nullable disable
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
public class HelixStreamsResponse
{
public class PaginationData
{
[JsonPropertyName("cursor")]
public string Cursor { get; set; }
}
public class StreamData
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("user_id")]
public string UserId { get; set; }
[JsonPropertyName("user_login")]
public string UserLogin { get; set; }
[JsonPropertyName("user_name")]
public string UserName { get; set; }
[JsonPropertyName("game_id")]
public string GameId { get; set; }
[JsonPropertyName("game_name")]
public string GameName { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("viewer_count")]
public int ViewerCount { get; set; }
[JsonPropertyName("started_at")]
public DateTime StartedAt { get; set; }
[JsonPropertyName("language")]
public string Language { get; set; }
[JsonPropertyName("thumbnail_url")]
public string ThumbnailUrl { get; set; }
[JsonPropertyName("tag_ids")]
public List<string> TagIds { get; set; }
[JsonPropertyName("is_mature")]
public bool IsMature { get; set; }
}
[JsonPropertyName("data")]
public List<StreamData> Data { get; set; }
[JsonPropertyName("pagination")]
public PaginationData Pagination { get; set; }
}

View File

@@ -0,0 +1,46 @@
#nullable disable
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
public class HelixUsersResponse
{
public class User
{
[JsonPropertyName("id")]
public string Id { get; set; }
[JsonPropertyName("login")]
public string Login { get; set; }
[JsonPropertyName("display_name")]
public string DisplayName { get; set; }
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("broadcaster_type")]
public string BroadcasterType { get; set; }
[JsonPropertyName("description")]
public string Description { get; set; }
[JsonPropertyName("profile_image_url")]
public string ProfileImageUrl { get; set; }
[JsonPropertyName("offline_image_url")]
public string OfflineImageUrl { get; set; }
[JsonPropertyName("view_count")]
public int ViewCount { get; set; }
[JsonPropertyName("email")]
public string Email { get; set; }
[JsonPropertyName("created_at")]
public DateTime CreatedAt { get; set; }
}
[JsonPropertyName("data")]
public List<User> Data { get; set; }
}

View File

@@ -0,0 +1,157 @@
#nullable disable
using Newtonsoft.Json;
namespace NadekoBot.Modules.Searches.Common;
public class PicartoChannelResponse
{
[JsonProperty("user_id")]
public int UserId { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("avatar")]
public string Avatar { get; set; }
[JsonProperty("online")]
public bool Online { get; set; }
[JsonProperty("viewers")]
public int Viewers { get; set; }
[JsonProperty("viewers_total")]
public int ViewersTotal { get; set; }
[JsonProperty("thumbnails")]
public Thumbnails Thumbnails { get; set; }
[JsonProperty("followers")]
public int Followers { get; set; }
[JsonProperty("subscribers")]
public int Subscribers { get; set; }
[JsonProperty("adult")]
public bool Adult { get; set; }
[JsonProperty("category")]
public string Category { get; set; }
[JsonProperty("account_type")]
public string AccountType { get; set; }
[JsonProperty("commissions")]
public bool Commissions { get; set; }
[JsonProperty("recordings")]
public bool Recordings { get; set; }
[JsonProperty("title")]
public string Title { get; set; }
[JsonProperty("description_panels")]
public List<DescriptionPanel> DescriptionPanels { get; set; }
[JsonProperty("private")]
public bool Private { get; set; }
[JsonProperty("private_message")]
public string PrivateMessage { get; set; }
[JsonProperty("gaming")]
public bool Gaming { get; set; }
[JsonProperty("chat_settings")]
public ChatSettings ChatSettings { get; set; }
[JsonProperty("last_live")]
public DateTime LastLive { get; set; }
[JsonProperty("tags")]
public List<string> Tags { get; set; }
[JsonProperty("multistream")]
public List<Multistream> Multistream { get; set; }
[JsonProperty("languages")]
public List<Language> Languages { get; set; }
[JsonProperty("following")]
public bool Following { get; set; }
}
public class Thumbnails
{
[JsonProperty("web")]
public string Web { get; set; }
[JsonProperty("web_large")]
public string WebLarge { get; set; }
[JsonProperty("mobile")]
public string Mobile { get; set; }
[JsonProperty("tablet")]
public string Tablet { get; set; }
}
public class DescriptionPanel
{
[JsonProperty("title")]
public string Title { get; set; }
[JsonProperty("body")]
public string Body { get; set; }
[JsonProperty("image")]
public string Image { get; set; }
[JsonProperty("image_link")]
public string ImageLink { get; set; }
[JsonProperty("button_text")]
public string ButtonText { get; set; }
[JsonProperty("button_link")]
public string ButtonLink { get; set; }
[JsonProperty("position")]
public int Position { get; set; }
}
public class ChatSettings
{
[JsonProperty("guest_chat")]
public bool GuestChat { get; set; }
[JsonProperty("links")]
public bool Links { get; set; }
[JsonProperty("level")]
public int Level { get; set; }
}
public class Multistream
{
[JsonProperty("user_id")]
public int UserId { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("online")]
public bool Online { get; set; }
[JsonProperty("adult")]
public bool Adult { get; set; }
}
public class Language
{
[JsonProperty("id")]
public int Id { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
}

View File

@@ -0,0 +1,21 @@
#nullable disable
using NadekoBot.Db.Models;
namespace NadekoBot.Modules.Searches.Common;
public record StreamData
{
public FollowedStream.FType StreamType { get; set; }
public string Name { get; set; }
public string UniqueName { get; set; }
public int Viewers { get; set; }
public string Title { get; set; }
public string Game { get; set; }
public string Preview { get; set; }
public bool IsLive { get; set; }
public string StreamUrl { get; set; }
public string AvatarUrl { get; set; }
public StreamDataKey CreateKey()
=> new(StreamType, UniqueName.ToLower());
}

View File

@@ -0,0 +1,17 @@
#nullable disable
using NadekoBot.Db.Models;
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Searches.Common;
public readonly struct StreamDataKey
{
public FollowedStream.FType Type { get; init; }
public string Name { get; init; }
public StreamDataKey(FollowedStream.FType type, string name)
{
Type = type;
Name = name;
}
}

View File

@@ -0,0 +1,61 @@
#nullable disable
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
public class TrovoGetUsersResponse
{
[JsonPropertyName("is_live")]
public bool IsLive { get; set; }
[JsonPropertyName("category_id")]
public string CategoryId { get; set; }
[JsonPropertyName("category_name")]
public string CategoryName { get; set; }
[JsonPropertyName("live_title")]
public string LiveTitle { get; set; }
[JsonPropertyName("audi_type")]
public string AudiType { get; set; }
[JsonPropertyName("language_code")]
public string LanguageCode { get; set; }
[JsonPropertyName("thumbnail")]
public string Thumbnail { get; set; }
[JsonPropertyName("current_viewers")]
public int CurrentViewers { get; set; }
[JsonPropertyName("followers")]
public int Followers { get; set; }
[JsonPropertyName("streamer_info")]
public string StreamerInfo { get; set; }
[JsonPropertyName("profile_pic")]
public string ProfilePic { get; set; }
[JsonPropertyName("channel_url")]
public string ChannelUrl { get; set; }
[JsonPropertyName("created_at")]
public string CreatedAt { get; set; }
[JsonPropertyName("subscriber_num")]
public int SubscriberNum { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; }
[JsonPropertyName("social_links")]
public List<TrovoSocialLink> SocialLinks { get; set; }
[JsonPropertyName("started_at")]
public string StartedAt { get; set; }
[JsonPropertyName("ended_at")]
public string EndedAt { get; set; }
}

View File

@@ -0,0 +1,10 @@
#nullable disable
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
public class TrovoRequestData
{
[JsonPropertyName("username")]
public string Username { get; set; }
}

View File

@@ -0,0 +1,13 @@
#nullable disable
using System.Text.Json.Serialization;
namespace NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
public class TrovoSocialLink
{
[JsonPropertyName("type")]
public string Type { get; set; }
[JsonPropertyName("url")]
public string Url { get; set; }
}

View File

@@ -0,0 +1,114 @@
#nullable disable
using Newtonsoft.Json;
namespace NadekoBot.Modules.Searches.Common;
public class TwitchResponseV5
{
public List<Stream> Streams { get; set; }
public class Channel
{
[JsonProperty("_id")]
public int Id { get; set; }
[JsonProperty("broadcaster_language")]
public string BroadcasterLanguage { get; set; }
[JsonProperty("created_at")]
public DateTime CreatedAt { get; set; }
[JsonProperty("display_name")]
public string DisplayName { get; set; }
[JsonProperty("followers")]
public int Followers { get; set; }
[JsonProperty("game")]
public string Game { get; set; }
[JsonProperty("language")]
public string Language { get; set; }
[JsonProperty("logo")]
public string Logo { get; set; }
[JsonProperty("mature")]
public bool Mature { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("partner")]
public bool Partner { get; set; }
[JsonProperty("profile_banner")]
public string ProfileBanner { get; set; }
[JsonProperty("profile_banner_background_color")]
public object ProfileBannerBackgroundColor { get; set; }
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("updated_at")]
public DateTime UpdatedAt { get; set; }
[JsonProperty("url")]
public string Url { get; set; }
[JsonProperty("video_banner")]
public string VideoBanner { get; set; }
[JsonProperty("views")]
public int Views { get; set; }
}
public class Preview
{
[JsonProperty("large")]
public string Large { get; set; }
[JsonProperty("medium")]
public string Medium { get; set; }
[JsonProperty("small")]
public string Small { get; set; }
[JsonProperty("template")]
public string Template { get; set; }
}
public class Stream
{
[JsonProperty("_id")]
public long Id { get; set; }
[JsonProperty("average_fps")]
public double AverageFps { get; set; }
[JsonProperty("channel")]
public Channel Channel { get; set; }
[JsonProperty("created_at")]
public DateTime CreatedAt { get; set; }
[JsonProperty("delay")]
public double Delay { get; set; }
[JsonProperty("game")]
public string Game { get; set; }
[JsonProperty("is_playlist")]
public bool IsPlaylist { get; set; }
[JsonProperty("preview")]
public Preview Preview { get; set; }
[JsonProperty("video_height")]
public int VideoHeight { get; set; }
[JsonProperty("viewers")]
public int Viewers { get; set; }
}
}

View File

@@ -0,0 +1,37 @@
#nullable disable
using Newtonsoft.Json;
namespace NadekoBot.Modules.Searches.Common;
public class TwitchUsersResponseV5
{
[JsonProperty("users")]
public List<User> Users { get; set; }
public class User
{
[JsonProperty("_id")]
public string Id { get; set; }
// [JsonProperty("bio")]
// public string Bio { get; set; }
//
// [JsonProperty("created_at")]
// public DateTime CreatedAt { get; set; }
//
// [JsonProperty("display_name")]
// public string DisplayName { get; set; }
//
// [JsonProperty("logo")]
// public string Logo { get; set; }
//
// [JsonProperty("name")]
// public string Name { get; set; }
//
// [JsonProperty("type")]
// public string Type { get; set; }
//
// [JsonProperty("updated_at")]
// public DateTime UpdatedAt { get; set; }
}
}

View File

@@ -0,0 +1,216 @@
using Nadeko.Common;
using NadekoBot.Db.Models;
using NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
namespace NadekoBot.Modules.Searches.Common.StreamNotifications;
public class NotifChecker
{
public event Func<List<StreamData>, Task> OnStreamsOffline = _ => Task.CompletedTask;
public event Func<List<StreamData>, Task> OnStreamsOnline = _ => Task.CompletedTask;
private readonly IReadOnlyDictionary<FollowedStream.FType, Provider> _streamProviders;
private readonly HashSet<(FollowedStream.FType, string)> _offlineBuffer;
private readonly ConcurrentDictionary<StreamDataKey, StreamData?> _cache = new();
public NotifChecker(
IHttpClientFactory httpClientFactory,
IBotCredsProvider credsProvider)
{
_streamProviders = new Dictionary<FollowedStream.FType, Provider>()
{
{ FollowedStream.FType.Twitch, new TwitchHelixProvider(httpClientFactory, credsProvider) },
{ FollowedStream.FType.Picarto, new PicartoProvider(httpClientFactory) },
{ FollowedStream.FType.Trovo, new TrovoProvider(httpClientFactory, credsProvider) }
};
_offlineBuffer = new();
}
// gets all streams which have been failing for more than the provided timespan
public IEnumerable<StreamDataKey> GetFailingStreams(TimeSpan duration, bool remove = false)
{
var toReturn = _streamProviders
.SelectMany(prov => prov.Value
.FailingStreams
.Where(fs => DateTime.UtcNow - fs.Value > duration)
.Select(fs => new StreamDataKey(prov.Value.Platform, fs.Key)))
.ToList();
if (remove)
{
foreach (var toBeRemoved in toReturn)
_streamProviders[toBeRemoved.Type].ClearErrorsFor(toBeRemoved.Name);
}
return toReturn;
}
public Task RunAsync()
=> Task.Run(async () =>
{
while (true)
{
try
{
var allStreamData = GetAllData();
var oldStreamDataDict = allStreamData
// group by type
.GroupBy(entry => entry.Key.Type)
.ToDictionary(entry => entry.Key,
entry => entry.AsEnumerable()
.ToDictionary(x => x.Key.Name, x => x.Value));
var newStreamData = await oldStreamDataDict
.Select(x =>
{
// get all stream data for the streams of this type
if (_streamProviders.TryGetValue(x.Key,
out var provider))
{
return provider.GetStreamDataAsync(x.Value
.Select(entry => entry.Key)
.ToList());
}
// this means there's no provider for this stream data, (and there was before?)
return Task.FromResult<IReadOnlyCollection<StreamData>>(
new List<StreamData>());
})
.WhenAll();
var newlyOnline = new List<StreamData>();
var newlyOffline = new List<StreamData>();
// go through all new stream data, compare them with the old ones
foreach (var newData in newStreamData.SelectMany(x => x))
{
// update cached data
var key = newData.CreateKey();
// compare old data with new data
if (!oldStreamDataDict.TryGetValue(key.Type, out var typeDict)
|| !typeDict.TryGetValue(key.Name, out var oldData)
|| oldData is null)
{
AddLastData(key, newData, true);
continue;
}
// fill with last known game in case it's empty
if (string.IsNullOrWhiteSpace(newData.Game))
newData.Game = oldData.Game;
AddLastData(key, newData, true);
// if the stream is offline, we need to check if it was
// marked as offline once previously
// if it was, that means this is second time we're getting offline
// status for that stream -> notify subscribers
// Note: This is done because twitch api will sometimes return an offline status
// shortly after the stream is already online, which causes duplicate notifications.
// (stream is online -> stream is offline -> stream is online again (and stays online))
// This offlineBuffer will make it so that the stream has to be marked as offline TWICE
// before it sends an offline notification to the subscribers.
var streamId = (key.Type, key.Name);
if (!newData.IsLive && _offlineBuffer.Remove(streamId))
newlyOffline.Add(newData);
else if (newData.IsLive != oldData.IsLive)
{
if (newData.IsLive)
{
_offlineBuffer.Remove(streamId);
newlyOnline.Add(newData);
}
else
{
_offlineBuffer.Add(streamId);
// newlyOffline.Add(newData);
}
}
}
var tasks = new List<Task>
{
Task.Delay(30_000)
};
if (newlyOnline.Count > 0)
tasks.Add(OnStreamsOnline(newlyOnline));
if (newlyOffline.Count > 0)
tasks.Add(OnStreamsOffline(newlyOffline));
await Task.WhenAll(tasks);
}
catch (Exception ex)
{
Log.Error(ex, "Error getting stream notifications: {ErrorMessage}", ex.Message);
}
}
});
public bool AddLastData(StreamDataKey key, StreamData? data, bool replace)
{
if (replace)
{
_cache[key] = data;
return true;
}
return _cache.TryAdd(key, data);
}
public void DeleteLastData(StreamDataKey key)
=> _cache.TryRemove(key, out _);
public Dictionary<StreamDataKey, StreamData?> GetAllData()
=> _cache.ToDictionary(x => x.Key, x => x.Value);
public async Task<StreamData?> GetStreamDataByUrlAsync(string url)
{
// loop through all providers and see which regex matches
foreach (var (_, provider) in _streamProviders)
{
var isValid = await provider.IsValidUrl(url);
if (!isValid)
continue;
// if it's not a valid url, try another provider
var data = await provider.GetStreamDataByUrlAsync(url);
return data;
}
// if no provider found, return null
return null;
}
/// <summary>
/// Return currently available stream data, get new one if none available, and start tracking the stream.
/// </summary>
/// <param name="url">Url of the stream</param>
/// <returns>Stream data, if any</returns>
public async Task<StreamData?> TrackStreamByUrlAsync(string url)
{
var data = await GetStreamDataByUrlAsync(url);
EnsureTracked(data);
return data;
}
/// <summary>
/// Make sure a stream is tracked using its stream data.
/// </summary>
/// <param name="data">Data to try to track if not already tracked</param>
/// <returns>Whether it's newly added</returns>
private bool EnsureTracked(StreamData? data)
{
// something failed, don't add anything to cache
if (data is null)
return false;
// if stream is found, add it to the cache for tracking only if it doesn't already exist
// because stream will be checked and events will fire in a loop. We don't want to override old state
return AddLastData(data.CreateKey(), data, false);
}
public void UntrackStreamByKey(in StreamDataKey key)
=> DeleteLastData(key);
}

View File

@@ -0,0 +1,103 @@
using NadekoBot.Db.Models;
using Newtonsoft.Json;
using System.Text.RegularExpressions;
namespace NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
public class PicartoProvider : Provider
{
private static Regex Regex { get; } = new(@"picarto.tv/(?<name>.+[^/])/?",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public override FollowedStream.FType Platform
=> FollowedStream.FType.Picarto;
private readonly IHttpClientFactory _httpClientFactory;
public PicartoProvider(IHttpClientFactory httpClientFactory)
=> _httpClientFactory = httpClientFactory;
public override Task<bool> IsValidUrl(string url)
{
var match = Regex.Match(url);
if (!match.Success)
return Task.FromResult(false);
// var username = match.Groups["name"].Value;
return Task.FromResult(true);
}
public override Task<StreamData?> GetStreamDataByUrlAsync(string url)
{
var match = Regex.Match(url);
if (match.Success)
{
var name = match.Groups["name"].Value;
return GetStreamDataAsync(name);
}
return Task.FromResult<StreamData?>(null);
}
public override async Task<StreamData?> GetStreamDataAsync(string login)
{
var data = await GetStreamDataAsync(new List<string>
{
login
});
return data.FirstOrDefault();
}
public override async Task<IReadOnlyCollection<StreamData>> GetStreamDataAsync(List<string> logins)
{
if (logins.Count == 0)
return new List<StreamData>();
using var http = _httpClientFactory.CreateClient();
var toReturn = new List<StreamData>();
foreach (var login in logins)
{
try
{
http.DefaultRequestHeaders.Accept.Add(new("application/json"));
// get id based on the username
using var res = await http.GetAsync($"https://api.picarto.tv/v1/channel/name/{login}");
if (!res.IsSuccessStatusCode)
continue;
var userData =
JsonConvert.DeserializeObject<PicartoChannelResponse>(await res.Content.ReadAsStringAsync())!;
toReturn.Add(ToStreamData(userData));
_failingStreams.TryRemove(login, out _);
}
catch (Exception ex)
{
Log.Warning("Something went wrong retreiving {StreamPlatform} stream data for {Login}: {ErrorMessage}",
Platform,
login,
ex.Message);
_failingStreams.TryAdd(login, DateTime.UtcNow);
}
}
return toReturn;
}
private StreamData ToStreamData(PicartoChannelResponse stream)
=> new()
{
StreamType = FollowedStream.FType.Picarto,
Name = stream.Name,
UniqueName = stream.Name,
Viewers = stream.Viewers,
Title = stream.Title,
IsLive = stream.Online,
Preview = stream.Thumbnails.Web,
Game = stream.Category,
StreamUrl = $"https://picarto.tv/{stream.Name}",
AvatarUrl = stream.Avatar
};
}

View File

@@ -0,0 +1,63 @@
using NadekoBot.Db.Models;
namespace NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
/// <summary>
/// Abstract class implemented by providers of all supported platforms
/// </summary>
public abstract class Provider
{
/// <summary>
/// Type of the platform.
/// </summary>
public abstract FollowedStream.FType Platform { get; }
/// <summary>
/// Gets the stream usernames which fail to execute due to an error, and when they started throwing errors.
/// This can happen if stream name is invalid, or if the stream doesn't exist anymore.
/// Override to provide a custom implementation
/// </summary>
public virtual IReadOnlyDictionary<string, DateTime> FailingStreams
=> _failingStreams;
/// <summary>
/// When was the first time the stream continually had errors while being retrieved
/// </summary>
protected readonly ConcurrentDictionary<string, DateTime> _failingStreams = new();
/// <summary>
/// Checks whether the specified url is a valid stream url for this platform.
/// </summary>
/// <param name="url">Url to check</param>
/// <returns>True if valid, otherwise false</returns>
public abstract Task<bool> IsValidUrl(string url);
/// <summary>
/// Gets stream data of the stream on the specified url on this <see cref="Platform" />
/// </summary>
/// <param name="url">Url of the stream</param>
/// <returns><see cref="StreamData" /> of the specified stream. Null if none found</returns>
public abstract Task<StreamData?> GetStreamDataByUrlAsync(string url);
/// <summary>
/// Gets stream data of the specified id/username on this <see cref="Platform" />
/// </summary>
/// <param name="login">Name (or id where applicable) of the user on the platform</param>
/// <returns><see cref="StreamData" /> of the user. Null if none found</returns>
public abstract Task<StreamData?> GetStreamDataAsync(string login);
/// <summary>
/// Gets stream data of all specified ids/usernames on this <see cref="Platform" />
/// </summary>
/// <param name="usernames">List of ids/usernames</param>
/// <returns><see cref="StreamData" /> of all users, in the same order. Null for every id/user not found.</returns>
public abstract Task<IReadOnlyCollection<StreamData>> GetStreamDataAsync(List<string> usernames);
/// <summary>
/// Unmark the stream as errored. You should override this method
/// if you've overridden the <see cref="FailingStreams"/> property.
/// </summary>
/// <param name="login"></param>
public virtual void ClearErrorsFor(string login)
=> _failingStreams.Clear();
}

View File

@@ -0,0 +1,126 @@
using NadekoBot.Db.Models;
using System.Net.Http.Json;
using System.Text.RegularExpressions;
namespace NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
public class TrovoProvider : Provider
{
private readonly IHttpClientFactory _httpClientFactory;
public override FollowedStream.FType Platform
=> FollowedStream.FType.Trovo;
private readonly Regex _urlRegex = new(@"trovo.live\/(?<channel>[\w\d\-_]+)/?", RegexOptions.Compiled);
private readonly IBotCredsProvider _creds;
public TrovoProvider(IHttpClientFactory httpClientFactory, IBotCredsProvider creds)
{
(_httpClientFactory, _creds) = (httpClientFactory, creds);
if (string.IsNullOrWhiteSpace(creds.GetCreds().TrovoClientId))
{
Log.Warning("""
Trovo streams are using a default clientId.
If you are experiencing ratelimits, you should create your own application at: https://developer.trovo.live/
""");
}
}
public override Task<bool> IsValidUrl(string url)
=> Task.FromResult(_urlRegex.IsMatch(url));
public override Task<StreamData?> GetStreamDataByUrlAsync(string url)
{
var match = _urlRegex.Match(url);
if (match.Length == 0)
return Task.FromResult(default(StreamData?));
return GetStreamDataAsync(match.Groups["channel"].Value);
}
public override async Task<StreamData?> GetStreamDataAsync(string login)
{
using var http = _httpClientFactory.CreateClient();
var trovoClientId = _creds.GetCreds().TrovoClientId;
if (string.IsNullOrWhiteSpace(trovoClientId))
{
trovoClientId = "8b3cc4719b7051803099661a3265e50b";
}
http.DefaultRequestHeaders.Clear();
http.DefaultRequestHeaders.Add("Accept", "application/json");
http.DefaultRequestHeaders.Add("Client-ID", trovoClientId);
// trovo ratelimit is very generous (1200 per minute)
// so there is no need for ratelimit checks atm
try
{
using var res = await http.PostAsJsonAsync(
$"https://open-api.trovo.live/openplatform/channels/id",
new TrovoRequestData()
{
Username = login
});
res.EnsureSuccessStatusCode();
var data = await res.Content.ReadFromJsonAsync<TrovoGetUsersResponse>();
if (data is null)
{
Log.Warning("An empty response received while retrieving stream data for trovo.live/{TrovoId}", login);
_failingStreams.TryAdd(login, DateTime.UtcNow);
return null;
}
_failingStreams.TryRemove(data.Username, out _);
return new()
{
IsLive = data.IsLive,
Game = data.CategoryName,
Name = data.Username,
Title = data.LiveTitle,
Viewers = data.CurrentViewers,
AvatarUrl = data.ProfilePic,
StreamType = Platform,
StreamUrl = data.ChannelUrl,
UniqueName = data.Username,
Preview = data.Thumbnail
};
}
catch (Exception ex)
{
Log.Warning(ex, "Error retrieving stream data for trovo.live/{TrovoId}", login);
_failingStreams.TryAdd(login, DateTime.UtcNow);
return null;
}
}
public override async Task<IReadOnlyCollection<StreamData>> GetStreamDataAsync(List<string> usernames)
{
var trovoClientId = _creds.GetCreds().TrovoClientId;
if (string.IsNullOrWhiteSpace(trovoClientId))
{
Log.Warning("Trovo streams will be ignored until TrovoClientId is added to creds.yml");
return Array.Empty<StreamData>();
}
var results = new List<StreamData>(usernames.Count);
foreach (var chunk in usernames.Chunk(10)
.Select(x => x.Select(GetStreamDataAsync)))
{
var chunkResults = await Task.WhenAll(chunk);
results.AddRange(chunkResults.Where(x => x is not null)!);
await Task.Delay(1000);
}
return results;
}
}

View File

@@ -0,0 +1,198 @@
using NadekoBot.Db.Models;
using System.Text.RegularExpressions;
using Nadeko.Common;
using TwitchLib.Api;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace NadekoBot.Modules.Searches.Common.StreamNotifications.Providers;
public sealed class TwitchHelixProvider : Provider
{
private readonly IHttpClientFactory _httpClientFactory;
private static Regex Regex { get; } = new(@"twitch.tv/(?<name>[\w\d\-_]+)/?",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public override FollowedStream.FType Platform
=> FollowedStream.FType.Twitch;
private readonly Lazy<TwitchAPI> _api;
private readonly string _clientId;
public TwitchHelixProvider(IHttpClientFactory httpClientFactory, IBotCredsProvider credsProvider)
{
_httpClientFactory = httpClientFactory;
var creds = credsProvider.GetCreds();
_clientId = creds.TwitchClientId;
var clientSecret = creds.TwitchClientSecret;
_api = new(() => new()
{
Helix =
{
Settings =
{
ClientId = _clientId,
Secret = clientSecret
}
}
});
}
private async Task<string?> EnsureTokenValidAsync()
=> await _api.Value.Auth.GetAccessTokenAsync();
public override Task<bool> IsValidUrl(string url)
{
var match = Regex.Match(url);
if (!match.Success)
{
return Task.FromResult(false);
}
return Task.FromResult(true);
}
public override Task<StreamData?> GetStreamDataByUrlAsync(string url)
{
var match = Regex.Match(url);
if (match.Success)
{
var name = match.Groups["name"].Value;
return GetStreamDataAsync(name);
}
return Task.FromResult<StreamData?>(null);
}
public override async Task<StreamData?> GetStreamDataAsync(string login)
{
var data = await GetStreamDataAsync(new List<string>
{
login
});
return data.FirstOrDefault();
}
public override async Task<IReadOnlyCollection<StreamData>> GetStreamDataAsync(List<string> logins)
{
if (logins.Count == 0)
{
return Array.Empty<StreamData>();
}
var token = await EnsureTokenValidAsync();
if (token is null)
{
Log.Warning("Twitch client id and client secret key are not added to creds.yml or incorrect");
return Array.Empty<StreamData>();
}
using var http = _httpClientFactory.CreateClient();
http.DefaultRequestHeaders.Clear();
http.DefaultRequestHeaders.Add("Client-Id", _clientId);
http.DefaultRequestHeaders.Add("Authorization", $"Bearer {token}");
var loginsSet = logins.Select(x => x.ToLowerInvariant())
.Distinct()
.ToHashSet();
var dataDict = new Dictionary<string, StreamData>();
foreach (var chunk in logins.Chunk(100))
{
try
{
var str = await http.GetStringAsync($"https://api.twitch.tv/helix/users"
+ $"?{chunk.Select(x => $"login={x}").Join('&')}"
+ $"&first=100");
var resObj = JsonSerializer.Deserialize<HelixUsersResponse>(str);
if (resObj?.Data is null || resObj.Data.Count == 0)
continue;
foreach (var user in resObj.Data)
{
var lowerLogin = user.Login.ToLowerInvariant();
if (loginsSet.Remove(lowerLogin))
{
dataDict[lowerLogin] = UserToStreamData(user);
}
}
}
catch (Exception ex)
{
Log.Warning(ex, "Something went wrong retreiving {StreamPlatform} streams", Platform);
return new List<StreamData>();
}
}
// any item left over loginsSet is an invalid username
foreach (var login in loginsSet)
{
_failingStreams.TryAdd(login, DateTime.UtcNow);
}
// only get streams for users which exist
foreach (var chunk in dataDict.Keys.Chunk(100))
{
try
{
var str = await http.GetStringAsync($"https://api.twitch.tv/helix/streams"
+ $"?{chunk.Select(x => $"user_login={x}").Join('&')}"
+ "&first=100");
var res = JsonSerializer.Deserialize<HelixStreamsResponse>(str);
if (res?.Data is null || res.Data.Count == 0)
{
continue;
}
foreach (var helixStreamData in res.Data)
{
var login = helixStreamData.UserLogin.ToLowerInvariant();
if (dataDict.TryGetValue(login, out var old))
{
dataDict[login] = FillStreamData(old, helixStreamData);
}
}
}
catch (Exception ex)
{
Log.Warning(ex, "Something went wrong retreiving {StreamPlatform} streams", Platform);
return new List<StreamData>();
}
}
return dataDict.Values;
}
private StreamData UserToStreamData(HelixUsersResponse.User user)
=> new()
{
UniqueName = user.Login,
Name = user.DisplayName,
AvatarUrl = user.ProfileImageUrl,
IsLive = false,
StreamUrl = $"https://twitch.tv/{user.Login}",
StreamType = FollowedStream.FType.Twitch,
Preview = user.OfflineImageUrl
};
private StreamData FillStreamData(StreamData partial, HelixStreamsResponse.StreamData apiData)
=> partial with
{
StreamType = FollowedStream.FType.Twitch,
Viewers = apiData.ViewerCount,
Title = apiData.Title,
IsLive = apiData.Type == "live",
Preview = apiData.ThumbnailUrl
.Replace("{width}", "640")
.Replace("{height}", "480"),
Game = apiData.GameName,
};
}