Updated Searches module

This commit is contained in:
Toastie (DCS Team) 2024-06-26 23:51:35 +12:00
parent 888994dd67
commit 0466701e28
Signed by: toastie_t0ast
GPG key ID: 27F3B6855AFD40A4
8 changed files with 77 additions and 383 deletions

View file

@ -130,7 +130,7 @@ public class CryptoService : IEService
await _getCryptoLock.WaitAsync(); await _getCryptoLock.WaitAsync();
try try
{ {
var data = await _cache.GetOrAddAsync(new("ellie:crypto_data"), var data = await _cache.GetOrAddAsync(new("nadeko:crypto_data"),
async () => async () =>
{ {
try try

View file

@ -1,6 +1,7 @@
using AngleSharp; using AngleSharp;
using CsvHelper; using CsvHelper;
using CsvHelper.Configuration; using CsvHelper.Configuration;
using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Text.Json; using System.Text.Json;
@ -22,46 +23,32 @@ public sealed class DefaultStockDataService : IStockDataService, IEService
using var http = _httpClientFactory.CreateClient(); using var http = _httpClientFactory.CreateClient();
var quoteHtmlPage = $"https://finance.yahoo.com/quote/{query.ToUpperInvariant()}"; var quoteHtmlPage = $"https://finance.yahoo.com/quote/{query.ToUpperInvariant()}";
var config = Configuration.Default.WithDefaultLoader(); var config = Configuration.Default.WithDefaultLoader();
using var document = await BrowsingContext.New(config).OpenAsync(quoteHtmlPage); using var document = await BrowsingContext.New(config).OpenAsync(quoteHtmlPage);
var divElem =
document.QuerySelector( var tickerName = document.QuerySelector("div.top > .left > .container > h1")
"#quote-header-info > div:nth-child(2) > div > div > h1"); ?.TextContent;
var tickerName = (divElem)?.TextContent;
if (tickerName is null)
return default;
var marketcap = document var marketcap = document
.QuerySelectorAll("table") .QuerySelector("li > span > fin-streamer[data-field='marketCap']")
.Skip(1)
.First()
.QuerySelector("tbody > tr > td:nth-child(2)")
?.TextContent; ?.TextContent;
var volume = document.QuerySelector("td[data-test='AVERAGE_VOLUME_3MONTH-value']") var volume = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketVolume']")
?.TextContent; ?.TextContent;
var close= document.QuerySelector("td[data-test='PREV_CLOSE-value']") var close = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketPreviousClose']")
?.TextContent ?? "0"; ?.TextContent
?? "0";
var price = document var price = document.QuerySelector("fin-streamer.livePrice > span")
.QuerySelector("#quote-header-info") ?.TextContent
?.QuerySelector("fin-streamer[data-field='regularMarketPrice']") ?? "0";
?.TextContent ?? close;
// var data = await http.GetFromJsonAsync<YahooQueryModel>(
// $"https://query1.finance.yahoo.com/v7/finance/quote?symbols={query}");
//
// if (data is null)
// return default;
// var symbol = data.QuoteResponse.Result.FirstOrDefault();
// if (symbol is null)
// return default;
return new() return new()
{ {

View file

@ -1,5 +1,4 @@
#nullable disable using CodeHollow.FeedReader;
using CodeHollow.FeedReader;
using EllieBot.Modules.Searches.Services; using EllieBot.Modules.Searches.Services;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@ -17,19 +16,21 @@ public partial class Searches
[RequireContext(ContextType.Guild)] [RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageMessages)] [UserPerm(GuildPerm.ManageMessages)]
[Priority(1)] [Priority(1)]
public Task YtUploadNotif(string url, [Leftover] string message = null) public Task YtUploadNotif(string url, [Leftover] string? message = null)
=> YtUploadNotif(url, null, message); => YtUploadNotif(url, null, message);
[Cmd] [Cmd]
[RequireContext(ContextType.Guild)] [RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageMessages)] [UserPerm(GuildPerm.ManageMessages)]
[Priority(2)] [Priority(2)]
public Task YtUploadNotif(string url, ITextChannel channel = null, [Leftover] string message = null) public Task YtUploadNotif(string url, ITextChannel? channel = null, [Leftover] string? message = null)
{ {
var m = _ytChannelRegex.Match(url); var m = _ytChannelRegex.Match(url);
if (!m.Success) if (!m.Success)
return Response().Error(strs.invalid_input).SendAsync(); return Response().Error(strs.invalid_input).SendAsync();
channel ??= ctx.Channel as ITextChannel;
if (!((IGuildUser)ctx.User).GetPermissions(channel).MentionEveryone) if (!((IGuildUser)ctx.User).GetPermissions(channel).MentionEveryone)
message = message?.SanitizeAllMentions(); message = message?.SanitizeAllMentions();
@ -42,7 +43,7 @@ public partial class Searches
[RequireContext(ContextType.Guild)] [RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageMessages)] [UserPerm(GuildPerm.ManageMessages)]
[Priority(0)] [Priority(0)]
public Task Feed(string url, [Leftover] string message = null) public Task Feed(string url, [Leftover] string? message = null)
=> Feed(url, null, message); => Feed(url, null, message);
@ -50,7 +51,7 @@ public partial class Searches
[RequireContext(ContextType.Guild)] [RequireContext(ContextType.Guild)]
[UserPerm(GuildPerm.ManageMessages)] [UserPerm(GuildPerm.ManageMessages)]
[Priority(1)] [Priority(1)]
public async Task Feed(string url, ITextChannel channel = null, [Leftover] string message = null) public async Task Feed(string url, ITextChannel? channel = null, [Leftover] string? message = null)
{ {
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
@ -59,10 +60,11 @@ public partial class Searches
return; return;
} }
channel ??= (ITextChannel)ctx.Channel;
if (!((IGuildUser)ctx.User).GetPermissions(channel).MentionEveryone) if (!((IGuildUser)ctx.User).GetPermissions(channel).MentionEveryone)
message = message?.SanitizeAllMentions(); message = message?.SanitizeAllMentions();
channel ??= (ITextChannel)ctx.Channel;
try try
{ {
await FeedReader.ReadAsync(url); await FeedReader.ReadAsync(url);

View file

@ -79,6 +79,15 @@ public class FeedsService : IEService
} }
} }
private DateTime? GetPubDate(FeedItem item)
{
if (item.PublishingDate is not null)
return item.PublishingDate;
if (item.SpecificItem is AtomFeedItem atomItem)
return atomItem.UpdatedDate;
return null;
}
public async Task<EmbedBuilder> TrackFeeds() public async Task<EmbedBuilder> TrackFeeds()
{ {
while (true) while (true)
@ -94,24 +103,32 @@ public class FeedsService : IEService
{ {
var feed = await FeedReader.ReadAsync(rssUrl); var feed = await FeedReader.ReadAsync(rssUrl);
var items = feed var items = new List<(FeedItem Item, DateTime LastUpdate)>();
.Items.Select(item => (Item: item, foreach (var item in feed.Items)
LastUpdate: item.PublishingDate?.ToUniversalTime() {
?? (item.SpecificItem as AtomFeedItem)?.UpdatedDate?.ToUniversalTime())) var pubDate = GetPubDate(item);
.Where(data => data.LastUpdate is not null)
.Select(data => (data.Item, LastUpdate: (DateTime)data.LastUpdate)) if (pubDate is null)
.OrderByDescending(data => data.LastUpdate) continue;
.Reverse() // start from the oldest
.ToList(); items.Add((item, pubDate.Value.ToUniversalTime()));
// show at most 3 items if you're behind
if (items.Count > 2)
break;
}
if (items.Count == 0)
continue;
if (!_lastPosts.TryGetValue(kvp.Key, out var lastFeedUpdate)) if (!_lastPosts.TryGetValue(kvp.Key, out var lastFeedUpdate))
{ {
lastFeedUpdate = _lastPosts[kvp.Key] = lastFeedUpdate = _lastPosts[kvp.Key] = items[0].LastUpdate;
items.Any() ? items[items.Count - 1].LastUpdate : DateTime.UtcNow;
} }
foreach (var (feedItem, itemUpdateDate) in items) for (var index = 1; index <= items.Count; index++)
{ {
var (feedItem, itemUpdateDate) = items[^index];
if (itemUpdateDate <= lastFeedUpdate) if (itemUpdateDate <= lastFeedUpdate)
continue; continue;
@ -168,27 +185,26 @@ public class FeedsService : IEService
if (!string.IsNullOrWhiteSpace(feedItem.Description)) if (!string.IsNullOrWhiteSpace(feedItem.Description))
embed.WithDescription(desc.TrimTo(2048)); embed.WithDescription(desc.TrimTo(2048));
//send the created embed to all subscribed channels
var feedSendTasks = kvp.Value
.Where(x => x.GuildConfig is not null)
.Select(x =>
{
var ch = _client.GetGuild(x.GuildConfig.GuildId)
?.GetTextChannel(x.ChannelId);
if (ch is null) var tasks = new List<Task>();
return null;
return _sender.Response(ch) foreach (var val in kvp.Value)
.Embed(embed) {
.Text(string.IsNullOrWhiteSpace(x.Message) var ch = _client.GetGuild(val.GuildConfig.GuildId).GetTextChannel(val.ChannelId);
? string.Empty
: x.Message)
.SendAsync();
})
.Where(x => x is not null);
allSendTasks.Add(feedSendTasks.WhenAll()); if (ch is null)
continue;
var sendTask = _sender.Response(ch)
.Embed(embed)
.Text(string.IsNullOrWhiteSpace(val.Message)
? string.Empty
: val.Message)
.SendAsync();
tasks.Add(sendTask);
}
allSendTasks.Add(tasks.WhenAll());
// as data retrieval was successful, reset error counter // as data retrieval was successful, reset error counter
ClearErrors(rssUrl); ClearErrors(rssUrl);

View file

@ -1,312 +0,0 @@
#nullable disable
using EllieBot.Modules.Searches.Common;
using EllieBot.Modules.Searches.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Globalization;
using System.Text;
namespace EllieBot.Modules.Searches;
public partial class Searches
{
[Group]
public partial class PathOfExileCommands : EllieModule<SearchesService>
{
private const string POE_URL = "https://www.pathofexile.com/character-window/get-characters?accountName=";
private const string PON_URL = "http://poe.ninja/api/Data/GetCurrencyOverview?league=";
private const string POGS_URL = "http://pathofexile.gamepedia.com/api.php?action=opensearch&search=";
private const string POG_URL =
"https://pathofexile.gamepedia.com/api.php?action=browsebysubject&format=json&subject=";
private const string POGI_URL =
"https://pathofexile.gamepedia.com/api.php?action=query&prop=imageinfo&iiprop=url&format=json&titles=File:";
private const string PROFILE_URL = "https://www.pathofexile.com/account/view-profile/";
private readonly IHttpClientFactory _httpFactory;
private Dictionary<string, string> currencyDictionary = new(StringComparer.OrdinalIgnoreCase)
{
{ "Chaos Orb", "Chaos Orb" },
{ "Orb of Alchemy", "Orb of Alchemy" },
{ "Jeweller's Orb", "Jeweller's Orb" },
{ "Exalted Orb", "Exalted Orb" },
{ "Mirror of Kalandra", "Mirror of Kalandra" },
{ "Vaal Orb", "Vaal Orb" },
{ "Orb of Alteration", "Orb of Alteration" },
{ "Orb of Scouring", "Orb of Scouring" },
{ "Divine Orb", "Divine Orb" },
{ "Orb of Annulment", "Orb of Annulment" },
{ "Master Cartographer's Sextant", "Master Cartographer's Sextant" },
{ "Journeyman Cartographer's Sextant", "Journeyman Cartographer's Sextant" },
{ "Apprentice Cartographer's Sextant", "Apprentice Cartographer's Sextant" },
{ "Blessed Orb", "Blessed Orb" },
{ "Orb of Regret", "Orb of Regret" },
{ "Gemcutter's Prism", "Gemcutter's Prism" },
{ "Glassblower's Bauble", "Glassblower's Bauble" },
{ "Orb of Fusing", "Orb of Fusing" },
{ "Cartographer's Chisel", "Cartographer's Chisel" },
{ "Chromatic Orb", "Chromatic Orb" },
{ "Orb of Augmentation", "Orb of Augmentation" },
{ "Blacksmith's Whetstone", "Blacksmith's Whetstone" },
{ "Orb of Transmutation", "Orb of Transmutation" },
{ "Armourer's Scrap", "Armourer's Scrap" },
{ "Scroll of Wisdom", "Scroll of Wisdom" },
{ "Regal Orb", "Regal Orb" },
{ "Chaos", "Chaos Orb" },
{ "Alch", "Orb of Alchemy" },
{ "Alchs", "Orb of Alchemy" },
{ "Jews", "Jeweller's Orb" },
{ "Jeweller", "Jeweller's Orb" },
{ "Jewellers", "Jeweller's Orb" },
{ "Jeweller's", "Jeweller's Orb" },
{ "X", "Exalted Orb" },
{ "Ex", "Exalted Orb" },
{ "Exalt", "Exalted Orb" },
{ "Exalts", "Exalted Orb" },
{ "Mirror", "Mirror of Kalandra" },
{ "Mirrors", "Mirror of Kalandra" },
{ "Vaal", "Vaal Orb" },
{ "Alt", "Orb of Alteration" },
{ "Alts", "Orb of Alteration" },
{ "Scour", "Orb of Scouring" },
{ "Scours", "Orb of Scouring" },
{ "Divine", "Divine Orb" },
{ "Annul", "Orb of Annulment" },
{ "Annulment", "Orb of Annulment" },
{ "Master Sextant", "Master Cartographer's Sextant" },
{ "Journeyman Sextant", "Journeyman Cartographer's Sextant" },
{ "Apprentice Sextant", "Apprentice Cartographer's Sextant" },
{ "Blessed", "Blessed Orb" },
{ "Regret", "Orb of Regret" },
{ "Regrets", "Orb of Regret" },
{ "Gcp", "Gemcutter's Prism" },
{ "Glassblowers", "Glassblower's Bauble" },
{ "Glassblower's", "Glassblower's Bauble" },
{ "Fusing", "Orb of Fusing" },
{ "Fuses", "Orb of Fusing" },
{ "Fuse", "Orb of Fusing" },
{ "Chisel", "Cartographer's Chisel" },
{ "Chisels", "Cartographer's Chisel" },
{ "Chance", "Orb of Chance" },
{ "Chances", "Orb of Chance" },
{ "Chrome", "Chromatic Orb" },
{ "Chromes", "Chromatic Orb" },
{ "Aug", "Orb of Augmentation" },
{ "Augmentation", "Orb of Augmentation" },
{ "Augment", "Orb of Augmentation" },
{ "Augments", "Orb of Augmentation" },
{ "Whetstone", "Blacksmith's Whetstone" },
{ "Whetstones", "Blacksmith's Whetstone" },
{ "Transmute", "Orb of Transmutation" },
{ "Transmutes", "Orb of Transmutation" },
{ "Armourers", "Armourer's Scrap" },
{ "Armourer's", "Armourer's Scrap" },
{ "Wisdom Scroll", "Scroll of Wisdom" },
{ "Wisdom Scrolls", "Scroll of Wisdom" },
{ "Regal", "Regal Orb" },
{ "Regals", "Regal Orb" }
};
public PathOfExileCommands(IHttpClientFactory httpFactory)
=> _httpFactory = httpFactory;
[Cmd]
public async Task PathOfExile(string usr, string league = "", int page = 1)
{
if (--page < 0)
return;
if (string.IsNullOrWhiteSpace(usr))
{
await Response().Error("Please provide an account name.").SendAsync();
return;
}
var characters = new List<Account>();
try
{
using var http = _httpFactory.CreateClient();
var res = await http.GetStringAsync($"{POE_URL}{usr}");
characters = JsonConvert.DeserializeObject<List<Account>>(res);
}
catch
{
var embed = _sender.CreateEmbed().WithDescription(GetText(strs.account_not_found)).WithErrorColor();
await Response().Embed(embed).SendAsync();
return;
}
if (!string.IsNullOrWhiteSpace(league))
characters.RemoveAll(c => c.League != league);
await Response()
.Paginated()
.Items(characters)
.PageSize(9)
.CurrentPage(page)
.Page((items, curPage) =>
{
var embed = _sender.CreateEmbed()
.WithAuthor($"Characters on {usr}'s account",
"https://web.poecdn.com/image/favicon/ogimage.png",
$"{PROFILE_URL}{usr}")
.WithOkColor();
if (characters.Count == 0)
return embed.WithDescription("This account has no characters.");
var sb = new StringBuilder();
sb.AppendLine($"```{"#",-5}{"Character Name",-23}{"League",-10}{"Class",-13}{"Level",-3}");
for (var i = 0; i < items.Count; i++)
{
var character = items[i];
sb.AppendLine(
$"#{i + 1 + (curPage * 9),-4}{character.Name,-23}{ShortLeagueName(character.League),-10}{character.Class,-13}{character.Level,-3}");
}
sb.AppendLine("```");
embed.WithDescription(sb.ToString());
return embed;
})
.SendAsync();
}
[Cmd]
public async Task PathOfExileLeagues()
{
var leagues = new List<Leagues>();
try
{
using var http = _httpFactory.CreateClient();
var res = await http.GetStringAsync("http://api.pathofexile.com/leagues?type=main&compact=1");
leagues = JsonConvert.DeserializeObject<List<Leagues>>(res);
}
catch
{
var eembed = _sender.CreateEmbed().WithDescription(GetText(strs.leagues_not_found)).WithErrorColor();
await Response().Embed(eembed).SendAsync();
return;
}
var embed = _sender.CreateEmbed()
.WithAuthor("Path of Exile Leagues",
"https://web.poecdn.com/image/favicon/ogimage.png",
"https://www.pathofexile.com")
.WithOkColor();
var sb = new StringBuilder();
sb.AppendLine($"```{"#",-5}{"League Name",-23}");
for (var i = 0; i < leagues.Count; i++)
{
var league = leagues[i];
sb.AppendLine($"#{i + 1,-4}{league.Id,-23}");
}
sb.AppendLine("```");
embed.WithDescription(sb.ToString());
await Response().Embed(embed).SendAsync();
}
[Cmd]
public async Task PathOfExileCurrency(
string leagueName,
string currencyName,
string convertName = "Chaos Orb")
{
if (string.IsNullOrWhiteSpace(leagueName))
{
await Response().Error("Please provide league name.").SendAsync();
return;
}
if (string.IsNullOrWhiteSpace(currencyName))
{
await Response().Error("Please provide currency name.").SendAsync();
return;
}
var cleanCurrency = ShortCurrencyName(currencyName);
var cleanConvert = ShortCurrencyName(convertName);
try
{
var res = $"{PON_URL}{leagueName}";
using var http = _httpFactory.CreateClient();
var obj = JObject.Parse(await http.GetStringAsync(res));
var chaosEquivalent = 0.0F;
var conversionEquivalent = 0.0F;
// poe.ninja API does not include a "chaosEquivalent" property for Chaos Orbs.
if (cleanCurrency == "Chaos Orb")
chaosEquivalent = 1.0F;
else
{
var currencyInput = obj["lines"]
.Values<JObject>()
.Where(i => i["currencyTypeName"].Value<string>() == cleanCurrency)
.FirstOrDefault();
chaosEquivalent = float.Parse(currencyInput["chaosEquivalent"].ToString(),
CultureInfo.InvariantCulture);
}
if (cleanConvert == "Chaos Orb")
conversionEquivalent = 1.0F;
else
{
var currencyOutput = obj["lines"]
.Values<JObject>()
.Where(i => i["currencyTypeName"].Value<string>() == cleanConvert)
.FirstOrDefault();
conversionEquivalent = float.Parse(currencyOutput["chaosEquivalent"].ToString(),
CultureInfo.InvariantCulture);
}
var embed = _sender.CreateEmbed()
.WithAuthor($"{leagueName} Currency Exchange",
"https://web.poecdn.com/image/favicon/ogimage.png",
"http://poe.ninja")
.AddField("Currency Type", cleanCurrency, true)
.AddField($"{cleanConvert} Equivalent", chaosEquivalent / conversionEquivalent, true)
.WithOkColor();
await Response().Embed(embed).SendAsync();
}
catch
{
var embed = _sender.CreateEmbed().WithDescription(GetText(strs.ninja_not_found)).WithErrorColor();
await Response().Embed(embed).SendAsync();
}
}
private string ShortCurrencyName(string str)
{
if (currencyDictionary.ContainsValue(str))
return str;
var currency = currencyDictionary[str];
return currency;
}
private static string ShortLeagueName(string str)
{
var league = str.Replace("Hardcore", "HC", StringComparison.InvariantCultureIgnoreCase);
return league;
}
}
}

View file

@ -2,6 +2,7 @@
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using EllieBot.Modules.Searches.Common; using EllieBot.Modules.Searches.Common;
using EllieBot.Modules.Searches.Services; using EllieBot.Modules.Searches.Services;
using EllieBot.Modules.Utility;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using SixLabors.ImageSharp; using SixLabors.ImageSharp;
@ -168,7 +169,7 @@ public partial class Searches : EllieModule<SearchesService>
.AddField("Rating", movie.ImdbRating, true) .AddField("Rating", movie.ImdbRating, true)
.AddField("Genre", movie.Genre, true) .AddField("Genre", movie.Genre, true)
.AddField("Year", movie.Year, true) .AddField("Year", movie.Year, true)
.WithImageUrl(movie.Poster)) .WithImageUrl(Uri.IsWellFormedUriString(movie.Poster, UriKind.Absolute) ? movie.Poster : null))
.SendAsync(); .SendAsync();
} }

View file

@ -126,7 +126,7 @@ public class SearchesService : IEService
{ {
query = query.Trim().ToLowerInvariant(); query = query.Trim().ToLowerInvariant();
return await _c.GetOrAddAsync(new($"ellie_weather_{query}"), return await _c.GetOrAddAsync(new($"nadeko_weather_{query}"),
async () => await GetWeatherDataFactory(query), async () => await GetWeatherDataFactory(query),
TimeSpan.FromHours(3)); TimeSpan.FromHours(3));
} }
@ -156,7 +156,7 @@ public class SearchesService : IEService
public Task<((string Address, DateTime Time, string TimeZoneName), TimeErrors?)> GetTimeDataAsync(string arg) public Task<((string Address, DateTime Time, string TimeZoneName), TimeErrors?)> GetTimeDataAsync(string arg)
=> GetTimeDataFactory(arg); => GetTimeDataFactory(arg);
//return _cache.GetOrAddCachedDataAsync($"ellie_time_{arg}", //return _cache.GetOrAddCachedDataAsync($"nadeko_time_{arg}",
// GetTimeDataFactory, // GetTimeDataFactory,
// arg, // arg,
// TimeSpan.FromMinutes(1)); // TimeSpan.FromMinutes(1));

View file

@ -40,7 +40,7 @@ public partial class SearchesConfig : ICloneable<SearchesConfig>
[Comment(""" [Comment("""
Set the searx instance urls in case you want to use 'searx' for either img or web search. Set the searx instance urls in case you want to use 'searx' for either img or web search.
Ellie will use a random one for each request. Nadeko will use a random one for each request.
Use a fully qualified url. Example: `https://my-searx-instance.mydomain.com` Use a fully qualified url. Example: `https://my-searx-instance.mydomain.com`
Instances specified must support 'format=json' query parameter. Instances specified must support 'format=json' query parameter.
- In case you're running your own searx instance, set - In case you're running your own searx instance, set
@ -57,7 +57,7 @@ public partial class SearchesConfig : ICloneable<SearchesConfig>
[Comment(""" [Comment("""
Set the invidious instance urls in case you want to use 'invidious' for `.youtube` search Set the invidious instance urls in case you want to use 'invidious' for `.youtube` search
Ellie will use a random one for each request. Nadeko will use a random one for each request.
These instances may be used for music queue functionality in the future. These instances may be used for music queue functionality in the future.
Use a fully qualified url. Example: https://my-invidious-instance.mydomain.com Use a fully qualified url. Example: https://my-invidious-instance.mydomain.com