From a6939b2220e7a3a12982d90861d836f9c14cc8a3 Mon Sep 17 00:00:00 2001
From: Toastie <toastie@toastiet0ast.com>
Date: Sat, 21 Sep 2024 14:42:25 +1200
Subject: [PATCH] Added Searches module

---
 .../Modules/Searches/Anime/AnimeResult.cs     |  41 ++
 .../Searches/Anime/AnimeSearchCommands.cs     |  77 ++
 .../Searches/Anime/AnimeSearchService.cs      |  79 +++
 .../Modules/Searches/Anime/MangaResult.cs     |  40 ++
 .../Modules/Searches/Crypto/CryptoCommands.cs | 231 ++++++
 .../Modules/Searches/Crypto/CryptoService.cs  | 266 +++++++
 .../Crypto/DefaultStockDataService.cs         | 112 +++
 .../Crypto/Drawing/CandleDrawingData.cs       |  12 +
 .../Drawing/IStockChartDrawingService.cs      |   8 +
 .../ImagesharpStockChartDrawingService.cs     | 200 ++++++
 .../Searches/Crypto/IStockDataService.cs      |   8 +
 .../Searches/Crypto/_common/CandleData.cs     |   8 +
 .../Searches/Crypto/_common/ImageData.cs      |   7 +
 .../Searches/Crypto/_common/QuoteResponse.cs  |  43 ++
 .../Searches/Crypto/_common/StockData.cs      |  15 +
 .../Searches/Crypto/_common/SymbolData.cs     |   3 +
 .../Crypto/_common/YahooFinanceCandleData.cs  |  12 +
 .../_common/YahooFinanceSearchResponse.cs     |  19 +
 .../_common/YahooFinanceSearchResponseItem.cs |  25 +
 .../Crypto/_common/YahooQueryModel.cs         |   9 +
 .../Modules/Searches/Feeds/FeedCommands.cs    | 148 ++++
 .../Modules/Searches/Feeds/FeedsService.cs    | 309 ++++++++
 src/EllieBot/Modules/Searches/JokeCommands.cs |  53 ++
 .../Modules/Searches/Osu/OsuCommands.cs       | 124 ++++
 .../Modules/Searches/Osu/OsuService.cs        | 227 ++++++
 .../Modules/Searches/PokemonSearchCommands.cs |  74 ++
 .../Searches/Religious/Common/BibleVerse.cs   |  13 +
 .../Searches/Religious/Common/BibleVerses.cs  |   7 +
 .../Searches/Religious/Common/QuranAyah.cs    |  19 +
 .../Religious/Common/QuranResponse.cs         |  15 +
 .../Searches/Religious/ReligiousApiService.cs |  62 ++
 .../Searches/Religious/ReligiousCommands.cs   |  60 ++
 .../Search/DefaultSearchServiceFactory.cs     |  65 ++
 .../Search/Google/GoogleCustomSearchResult.cs |  22 +
 .../Searches/Search/Google/GoogleImageData.cs |  12 +
 .../Search/Google/GoogleImageResult.cs        |  19 +
 .../Search/Google/GoogleImageResultEntry.cs   |  13 +
 .../Google/GoogleSearchResultInformation.cs   |  13 +
 .../Search/Google/GoogleSearchService.cs      |  66 ++
 .../Google/OfficialGoogleSearchResultEntry.cs |  19 +
 .../GoogleScrape/GoogleScrapeService.cs       | 121 ++++
 .../PlainGoogleScrapeSearchResult.cs          |   8 +
 .../GoogleScrape/PlainSearchResultEntry.cs    |   9 +
 .../GoogleScrape/PlainSearchResultInfo.cs     |   7 +
 .../Searches/Search/IImageSearchResult.cs     |  13 +
 .../Modules/Searches/Search/ISearchResult.cs  |   8 +
 .../Searches/Search/ISearchResultEntry.cs     |   9 +
 .../Search/ISearchResultInformation.cs        |   7 +
 .../Modules/Searches/Search/ISearchService.cs |   9 +
 .../Searches/Search/ISearchServiceFactory.cs  |  10 +
 .../Modules/Searches/Search/SearchCommands.cs | 205 ++++++
 .../Searches/Search/SearchServiceBase.cs      |   9 +
 .../Search/Searx/SearxImageSearchResult.cs    |  28 +
 .../Searx/SearxImageSearchResultEntry.cs      |  14 +
 .../Searches/Search/Searx/SearxInfobox.cs     |  30 +
 .../Search/Searx/SearxSearchAttribute.cs      |  15 +
 .../Search/Searx/SearxSearchResult.cs         |  47 ++
 .../Search/Searx/SearxSearchResultEntry.cs    |  51 ++
 .../Searx/SearxSearchResultInformation.cs     |   7 +
 .../Search/Searx/SearxSearchService.cs        |  77 ++
 .../Searches/Search/Searx/SearxUrlData.cs     |  15 +
 .../Search/Youtube/IYoutubeSearchService.cs   |   6 +
 .../Search/Youtube/InvidiousSearchResponse.cs |  63 ++
 .../Youtube/InvidiousYtSearchService.cs       |  47 ++
 .../Searches/Search/Youtube/VideoInfo.cs      |   9 +
 .../Youtube/YoutubeDataApiSearchService.cs    |  26 +
 .../Youtube/YtdlYoutubeSearchService.cs       |   7 +
 .../Youtube/YtdlpYoutubeSearchService.cs      |   7 +
 .../Search/Youtube/YtdlxServiceBase.cs        |  34 +
 src/EllieBot/Modules/Searches/Searches.cs     | 461 ++++++++++++
 .../Modules/Searches/SearchesService.cs       | 616 ++++++++++++++++
 .../StreamNotificationCommands.cs             | 195 +++++
 .../StreamNotificationService.cs              | 671 ++++++++++++++++++
 .../StreamOnlineMessageDeleterService.cs      |  99 +++
 .../Searches/Translate/ITranslateService.cs   |  17 +
 .../Searches/Translate/TranslateService.cs    | 224 ++++++
 .../Searches/Translate/TranslatorCommands.cs  |  95 +++
 src/EllieBot/Modules/Searches/XkcdCommands.cs |  97 +++
 .../Searches/YoutubeTrack/YtTrackService.cs   | 134 ++++
 .../Searches/YoutubeTrack/YtUploadCommands.cs |  54 ++
 .../Modules/Searches/_common/AtlExtensions.cs |  12 +
 .../_common/Config/ImgSearchEngine.cs         |   7 +
 .../Searches/_common/Config/SearchesConfig.cs |  85 +++
 .../_common/Config/SearchesConfigService.cs   |  66 ++
 .../_common/Config/WebSearchEngine.cs         |   9 +
 .../Modules/Searches/_common/CryptoData.cs    |  66 ++
 .../Modules/Searches/_common/DefineData.cs    |  10 +
 .../Modules/Searches/_common/DefineModel.cs   |  43 ++
 .../Modules/Searches/_common/E621Object.cs    |  24 +
 .../Modules/Searches/_common/ErrorType.cs     |   9 +
 .../Exceptions/StreamNotFoundException.cs     |  19 +
 .../Modules/Searches/_common/Extensions.cs    |   9 +
 .../Modules/Searches/_common/Gallery.cs       |  44 ++
 .../Searches/_common/GatariUserResponse.cs    |  52 ++
 .../_common/GatariUserStatsResponse.cs        |  76 ++
 .../Searches/_common/GoogleSearchResult.cs    |  16 +
 .../Searches/_common/HearthstoneCardData.cs   |  13 +
 .../Searches/_common/LowerCaseNamingPolicy.cs |  12 +
 .../Modules/Searches/_common/MagicItem.cs     |   8 +
 .../Modules/Searches/_common/MtgData.cs       |  26 +
 .../Modules/Searches/_common/NovelData.cs     |  14 +
 .../Modules/Searches/_common/OmdbMovie.cs     |  13 +
 .../Modules/Searches/_common/OsuMapData.cs    |   9 +
 .../Modules/Searches/_common/OsuUserBets.cs   |  58 ++
 .../Modules/Searches/_common/OsuUserData.cs   |  73 ++
 .../Searches/_common/PathOfExileModels.cs     |  40 ++
 .../Modules/Searches/_common/ShortenData.cs   |  10 +
 .../Modules/Searches/_common/SteamGameId.cs   |  35 +
 .../Models/HelixStreamsResponse.cs            |  64 ++
 .../Models/HelixUsersResponse.cs              |  46 ++
 .../Models/PicartoChannelResponse.cs          | 157 ++++
 .../StreamNotifications/Models/StreamData.cs  |  21 +
 .../Models/StreamDataKey.cs                   |  16 +
 .../Models/TrovoGetUsersResponse.cs           |  61 ++
 .../Models/TrovoRequestData.cs                |  10 +
 .../Models/TrovoSocialLink.cs                 |  13 +
 .../Models/TwitchResponseV5.cs                | 114 +++
 .../Models/TwitchUsersResponseV5.cs           |  37 +
 .../StreamNotifications/NotifChecker.cs       | 215 ++++++
 .../Providers/PicartoProvider.cs              | 100 +++
 .../StreamNotifications/Providers/Provider.cs |  63 ++
 .../Providers/TrovoProvider.cs                | 126 ++++
 .../Providers/TwitchHelixProvider.cs          | 194 +++++
 .../Modules/Searches/_common/TimeData.cs      |   9 +
 .../Modules/Searches/_common/TimeModels.cs    |  22 +
 .../Modules/Searches/_common/UrbanDef.cs      |  14 +
 .../Modules/Searches/_common/WeatherModels.cs |  67 ++
 .../Modules/Searches/_common/WikiaResponse.cs |   7 +
 .../Searches/_common/WikipediaApiModel.cs     |  18 +
 .../Searches/_common/WikipediaReply.cs        |  11 +
 .../Modules/Searches/_common/WoWJoke.cs       |  11 +
 131 files changed, 8370 insertions(+)
 create mode 100644 src/EllieBot/Modules/Searches/Anime/AnimeResult.cs
 create mode 100644 src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs
 create mode 100644 src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Anime/MangaResult.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/CryptoService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs
 create mode 100644 src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs
 create mode 100644 src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs
 create mode 100644 src/EllieBot/Modules/Searches/Feeds/FeedsService.cs
 create mode 100644 src/EllieBot/Modules/Searches/JokeCommands.cs
 create mode 100644 src/EllieBot/Modules/Searches/Osu/OsuCommands.cs
 create mode 100644 src/EllieBot/Modules/Searches/Osu/OsuService.cs
 create mode 100644 src/EllieBot/Modules/Searches/PokemonSearchCommands.cs
 create mode 100644 src/EllieBot/Modules/Searches/Religious/Common/BibleVerse.cs
 create mode 100644 src/EllieBot/Modules/Searches/Religious/Common/BibleVerses.cs
 create mode 100644 src/EllieBot/Modules/Searches/Religious/Common/QuranAyah.cs
 create mode 100644 src/EllieBot/Modules/Searches/Religious/Common/QuranResponse.cs
 create mode 100644 src/EllieBot/Modules/Searches/Religious/ReligiousApiService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Religious/ReligiousCommands.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/ISearchResult.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/ISearchService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/SearchCommands.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs
 create mode 100644 src/EllieBot/Modules/Searches/Searches.cs
 create mode 100644 src/EllieBot/Modules/Searches/SearchesService.cs
 create mode 100644 src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs
 create mode 100644 src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs
 create mode 100644 src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Translate/ITranslateService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Translate/TranslateService.cs
 create mode 100644 src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs
 create mode 100644 src/EllieBot/Modules/Searches/XkcdCommands.cs
 create mode 100644 src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs
 create mode 100644 src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/AtlExtensions.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/CryptoData.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/DefineData.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/DefineModel.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/E621Object.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/ErrorType.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/Exceptions/StreamNotFoundException.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/Extensions.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/Gallery.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/GatariUserResponse.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/GatariUserStatsResponse.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/GoogleSearchResult.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/HearthstoneCardData.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/LowerCaseNamingPolicy.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/MagicItem.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/MtgData.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/NovelData.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/OmdbMovie.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/OsuMapData.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/OsuUserBets.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/OsuUserData.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/PathOfExileModels.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/ShortenData.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/SteamGameId.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/HelixStreamsResponse.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/HelixUsersResponse.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/PicartoChannelResponse.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/StreamData.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/StreamDataKey.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoGetUsersResponse.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoRequestData.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoSocialLink.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TwitchResponseV5.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TwitchUsersResponseV5.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/NotifChecker.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/PicartoProvider.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/Provider.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/TrovoProvider.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/TwitchHelixProvider.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/TimeData.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/TimeModels.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/UrbanDef.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/WeatherModels.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/WikiaResponse.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/WikipediaApiModel.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/WikipediaReply.cs
 create mode 100644 src/EllieBot/Modules/Searches/_common/WoWJoke.cs

diff --git a/src/EllieBot/Modules/Searches/Anime/AnimeResult.cs b/src/EllieBot/Modules/Searches/Anime/AnimeResult.cs
new file mode 100644
index 0000000..c47eed7
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Anime/AnimeResult.cs
@@ -0,0 +1,41 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches.Common;
+
+public class AnimeResult
+{
+    [JsonPropertyName("id")]
+    public int Id { get; set; }
+
+    [JsonPropertyName("airing_status")]
+    public string AiringStatusParsed { get; set; }
+
+    [JsonPropertyName("title_english")]
+    public string TitleEnglish { get; set; }
+
+    [JsonPropertyName("total_episodes")]
+    public int TotalEpisodes { get; set; }
+    
+    [JsonPropertyName("description")]
+    public string Description { get; set; }
+
+    [JsonPropertyName("image_url_lge")]
+    public string ImageUrlLarge { get; set; }
+
+    [JsonPropertyName("genres")]
+    public string[] Genres { get; set; }
+
+    [JsonPropertyName("average_score")]
+    public float AverageScore { get; set; }
+
+    
+    public string AiringStatus
+        => AiringStatusParsed.ToTitleCase();
+    
+    public string Link
+        => "http://anilist.co/anime/" + Id;
+
+    public string Synopsis
+        => Description?[..(Description.Length > 500 ? 500 : Description.Length)] + "...";
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs b/src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs
new file mode 100644
index 0000000..4e95d39
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Anime/AnimeSearchCommands.cs
@@ -0,0 +1,77 @@
+#nullable disable
+using AngleSharp;
+using AngleSharp.Html.Dom;
+using EllieBot.Modules.Searches.Services;
+
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches
+{
+    [Group]
+    public partial class AnimeSearchCommands : EllieModule<AnimeSearchService>
+    {
+        [Cmd]
+        public async Task Anime([Leftover] string query)
+        {
+            if (string.IsNullOrWhiteSpace(query))
+                return;
+
+            var animeData = await _service.GetAnimeData(query);
+
+            if (animeData is null)
+            {
+                await Response().Error(strs.failed_finding_anime).SendAsync();
+                return;
+            }
+
+            var embed = _sender.CreateEmbed()
+                               .WithOkColor()
+                               .WithDescription(animeData.Synopsis.Replace("<br>",
+                                   Environment.NewLine,
+                                   StringComparison.InvariantCulture))
+                               .WithTitle(animeData.TitleEnglish)
+                               .WithUrl(animeData.Link)
+                               .WithImageUrl(animeData.ImageUrlLarge)
+                               .AddField(GetText(strs.episodes), animeData.TotalEpisodes.ToString(), true)
+                               .AddField(GetText(strs.status), animeData.AiringStatus, true)
+                               .AddField(GetText(strs.genres),
+                                   string.Join(",\n", animeData.Genres.Any() ? animeData.Genres : ["none"]),
+                                   true)
+                               .WithFooter($"{GetText(strs.score)} {animeData.AverageScore} / 100");
+            await Response().Embed(embed).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Manga([Leftover] string query)
+        {
+            if (string.IsNullOrWhiteSpace(query))
+                return;
+
+            var mangaData = await _service.GetMangaData(query);
+
+            if (mangaData is null)
+            {
+                await Response().Error(strs.failed_finding_manga).SendAsync();
+                return;
+            }
+
+            var embed = _sender.CreateEmbed()
+                               .WithOkColor()
+                               .WithDescription(mangaData.Synopsis.Replace("<br>",
+                                   Environment.NewLine,
+                                   StringComparison.InvariantCulture))
+                               .WithTitle(mangaData.TitleEnglish)
+                               .WithUrl(mangaData.Link)
+                               .WithImageUrl(mangaData.ImageUrlLge)
+                               .AddField(GetText(strs.chapters), mangaData.TotalChapters.ToString(), true)
+                               .AddField(GetText(strs.status), mangaData.PublishingStatus, true)
+                               .AddField(GetText(strs.genres),
+                                   string.Join(",\n", mangaData.Genres.Any() ? mangaData.Genres : ["none"]),
+                                   true)
+                               .WithFooter($"{GetText(strs.score)} {mangaData.AverageScore} / 100");
+
+            await Response().Embed(embed).SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs b/src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs
new file mode 100644
index 0000000..4cf1b01
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Anime/AnimeSearchService.cs
@@ -0,0 +1,79 @@
+#nullable disable
+using EllieBot.Modules.Searches.Common;
+using System.Net.Http.Json;
+
+namespace EllieBot.Modules.Searches.Services;
+
+public class AnimeSearchService : IEService
+{
+    private readonly IBotCache _cache;
+    private readonly IHttpClientFactory _httpFactory;
+
+    public AnimeSearchService(IBotCache cache, IHttpClientFactory httpFactory)
+    {
+        _cache = cache;
+        _httpFactory = httpFactory;
+    }
+
+    public async Task<AnimeResult> GetAnimeData(string query)
+    {
+        if (string.IsNullOrWhiteSpace(query))
+            throw new ArgumentNullException(nameof(query));
+        
+        TypedKey<AnimeResult> GetKey(string link)
+            => new TypedKey<AnimeResult>($"anime2:{link}");
+        
+        try
+        {
+            var suffix = Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
+            var link = $"https://aniapi.nadeko.bot/anime/{suffix}";
+            link = link.ToLowerInvariant();
+            var result = await _cache.GetAsync(GetKey(link));
+            if (!result.TryPickT0(out var data, out _))
+            {
+                using var http = _httpFactory.CreateClient();
+                data = await http.GetFromJsonAsync<AnimeResult>(link);
+
+                await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(12));
+            }
+
+            return data;
+        }
+        catch
+        {
+            return null;
+        }
+    }
+
+    public async Task<MangaResult> GetMangaData(string query)
+    {
+        if (string.IsNullOrWhiteSpace(query))
+            throw new ArgumentNullException(nameof(query));
+        
+        TypedKey<MangaResult> GetKey(string link)
+            => new TypedKey<MangaResult>($"manga2:{link}");
+        
+        try
+        {
+            var link = "https://aniapi.nadeko.bot/manga/"
+                       + Uri.EscapeDataString(query.Replace("/", " ", StringComparison.InvariantCulture));
+            link = link.ToLowerInvariant();
+            
+            var result = await _cache.GetAsync(GetKey(link));
+            if (!result.TryPickT0(out var data, out _))
+            {
+                using var http = _httpFactory.CreateClient();
+                data = await http.GetFromJsonAsync<MangaResult>(link);
+
+                await _cache.AddAsync(GetKey(link), data, expiry: TimeSpan.FromHours(3));
+            }
+
+
+            return data;
+        }
+        catch
+        {
+            return null;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Anime/MangaResult.cs b/src/EllieBot/Modules/Searches/Anime/MangaResult.cs
new file mode 100644
index 0000000..9a32703
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Anime/MangaResult.cs
@@ -0,0 +1,40 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches.Common;
+
+public class MangaResult
+{
+    [JsonPropertyName("id")]
+    public int Id { get; set; }
+
+    [JsonPropertyName("publishing_status")]
+    public string PublishingStatus { get; set; }
+
+    [JsonPropertyName("image_url_lge")]
+    public string ImageUrlLge { get; set; }
+
+    [JsonPropertyName("title_english")]
+    public string TitleEnglish { get; set; }
+
+    [JsonPropertyName("total_chapters")]
+    public int TotalChapters { get; set; }
+
+    [JsonPropertyName("total_volumes")]
+    public int TotalVolumes { get; set; }
+
+    [JsonPropertyName("description")]
+    public string Description { get; set; }
+    
+    [JsonPropertyName("genres")]
+    public string[] Genres { get; set; }
+
+    [JsonPropertyName("average_score")]
+    public float AverageScore { get; set; }
+
+    public string Link
+        => "http://anilist.co/manga/" + Id;
+
+    public string Synopsis
+        => Description?[..(Description.Length > 500 ? 500 : Description.Length)] + "...";
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs b/src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs
new file mode 100644
index 0000000..b5dabb3
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/CryptoCommands.cs
@@ -0,0 +1,231 @@
+#nullable disable
+using EllieBot.Modules.Searches.Services;
+using System.Globalization;
+
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches
+{
+    public partial class FinanceCommands : EllieModule<CryptoService>
+    {
+        private readonly IStockDataService _stocksService;
+        private readonly IStockChartDrawingService _stockDrawingService;
+
+        public FinanceCommands(IStockDataService stocksService, IStockChartDrawingService stockDrawingService)
+        {
+            _stocksService = stocksService;
+            _stockDrawingService = stockDrawingService;
+        }
+
+        [Cmd]
+        public async Task Stock([Leftover] string query)
+        {
+            using var typing = ctx.Channel.EnterTypingState();
+
+            var stock = await _stocksService.GetStockDataAsync(query);
+
+            if (stock is null)
+            {
+                var symbols = await _stocksService.SearchSymbolAsync(query);
+
+                if (symbols.Count == 0)
+                {
+                    await Response().Error(strs.not_found).SendAsync();
+                    return;
+                }
+
+                var symbol = symbols.First();
+                var promptEmbed = _sender.CreateEmbed()
+                                         .WithDescription(symbol.Description)
+                                         .WithTitle(GetText(strs.did_you_mean(symbol.Symbol)));
+
+                if (!await PromptUserConfirmAsync(promptEmbed))
+                    return;
+
+                query = symbol.Symbol;
+                stock = await _stocksService.GetStockDataAsync(query);
+
+                if (stock is null)
+                {
+                    await Response().Error(strs.not_found).SendAsync();
+                    return;
+                }
+            }
+
+            var candles = await _stocksService.GetCandleDataAsync(query);
+            var stockImageTask = _stockDrawingService.GenerateCombinedChartAsync(candles);
+
+            var localCulture = (CultureInfo)Culture.Clone();
+            localCulture.NumberFormat.CurrencySymbol = "$";
+
+            var sign = stock.Price >= stock.Close
+                ? "\\🔼"
+                : "\\🔻";
+
+            var change = (stock.Price - stock.Close).ToString("N2", Culture);
+            var changePercent = (1 - (stock.Close / stock.Price)).ToString("P1", Culture);
+
+            var price = stock.Price.ToString("C2", localCulture);
+
+            var eb = _sender.CreateEmbed()
+                            .WithOkColor()
+                            .WithAuthor(stock.Symbol)
+                            .WithUrl($"https://www.tradingview.com/chart/?symbol={stock.Symbol}")
+                            .WithTitle(stock.Name)
+                            .AddField(GetText(strs.price), $"{sign} **{price}**", true)
+                            .AddField(GetText(strs.market_cap), stock.MarketCap, true)
+                            .AddField(GetText(strs.volume_24h), stock.DailyVolume.ToString("C0", localCulture), true)
+                            .AddField("Change", $"{change} ({changePercent})", true)
+                            // .AddField("Change 50d", $"{sign50}{change50}", true)
+                            // .AddField("Change 200d", $"{sign200}{change200}", true)
+                            .WithFooter(stock.Exchange);
+
+            var message = await Response().Embed(eb).SendAsync();
+            await using var imageData = await stockImageTask;
+            if (imageData is null)
+                return;
+
+            var fileName = $"{query}-sparkline.{imageData.Extension}";
+            using var attachment = new FileAttachment(
+                imageData.FileData,
+                fileName
+            );
+            await message.ModifyAsync(mp =>
+            {
+                mp.Attachments =
+                    new(new[] { attachment });
+
+                mp.Embed = eb.WithImageUrl($"attachment://{fileName}").Build();
+            });
+        }
+
+
+        [Cmd]
+        public async Task Crypto(string name)
+        {
+            name = name?.ToUpperInvariant();
+
+            if (string.IsNullOrWhiteSpace(name))
+                return;
+
+            var (crypto, nearest) = await _service.GetCryptoData(name);
+
+            if (nearest is not null)
+            {
+                var embed = _sender.CreateEmbed()
+                                   .WithTitle(GetText(strs.crypto_not_found))
+                                   .WithDescription(
+                                       GetText(strs.did_you_mean(Format.Bold($"{nearest.Name} ({nearest.Symbol})"))));
+
+                if (await PromptUserConfirmAsync(embed))
+                    crypto = nearest;
+            }
+
+            if (crypto is null)
+            {
+                await Response().Error(strs.crypto_not_found).SendAsync();
+                return;
+            }
+
+            var usd = crypto.Quote["USD"];
+
+            var localCulture = (CultureInfo)Culture.Clone();
+            localCulture.NumberFormat.CurrencySymbol = "$";
+
+            var sevenDay = (usd.PercentChange7d / 100).ToString("P2", localCulture);
+            var lastDay = (usd.PercentChange24h / 100).ToString("P2", localCulture);
+            var price = usd.Price < 0.01
+                ? usd.Price.ToString(localCulture)
+                : usd.Price.ToString("C2", localCulture);
+
+            var volume = usd.Volume24h.ToString("C0", localCulture);
+            var marketCap = usd.MarketCap.ToString("C0", localCulture);
+            var dominance = (usd.MarketCapDominance / 100).ToString("P2", localCulture);
+
+            await using var sparkline = await _service.GetSparklineAsync(crypto.Id, usd.PercentChange7d >= 0);
+            var fileName = $"{crypto.Slug}_7d.png";
+
+            var toSend = _sender.CreateEmbed()
+                                .WithOkColor()
+                                .WithAuthor($"#{crypto.CmcRank}")
+                                .WithTitle($"{crypto.Name} ({crypto.Symbol})")
+                                .WithUrl($"https://coinmarketcap.com/currencies/{crypto.Slug}/")
+                                .WithThumbnailUrl(
+                                    $"https://s3.coinmarketcap.com/static/img/coins/128x128/{crypto.Id}.png")
+                                .AddField(GetText(strs.market_cap), marketCap, true)
+                                .AddField(GetText(strs.price), price, true)
+                                .AddField(GetText(strs.volume_24h), volume, true)
+                                .AddField(GetText(strs.change_7d_24h), $"{sevenDay} / {lastDay}", true)
+                                .AddField(GetText(strs.market_cap_dominance), dominance, true)
+                                .WithImageUrl($"attachment://{fileName}");
+
+            if (crypto.CirculatingSupply is double cs)
+            {
+                var csStr = cs.ToString("N0", localCulture);
+
+                if (crypto.MaxSupply is double ms)
+                {
+                    var perc = (cs / ms).ToString("P1", localCulture);
+
+                    toSend.AddField(GetText(strs.circulating_supply), $"{csStr} ({perc})", true);
+                }
+                else
+                {
+                    toSend.AddField(GetText(strs.circulating_supply), csStr, true);
+                }
+            }
+
+
+            await ctx.Channel.SendFileAsync(sparkline, fileName, embed: toSend.Build());
+        }
+
+        [Cmd]
+        public async Task Coins(int page = 1)
+        {
+            if (--page < 0)
+                return;
+
+            if (page > 25)
+                page = 25;
+
+            await Response()
+                  .Paginated()
+                  .PageItems(async (page) =>
+                  {
+                      var coins = await _service.GetTopCoins(page);
+                      return coins;
+                  })
+                  .PageSize(10)
+                  .Page((items, _) =>
+                  {
+                      var embed = _sender.CreateEmbed()
+                                         .WithOkColor();
+
+                      if (items.Count > 0)
+                      {
+                          foreach (var coin in items)
+                          {
+                              embed.AddField($"#{coin.MarketCapRank} {coin.Symbol} - {coin.Name}",
+                                  $"""
+                                   `Price:` {GetArrowEmoji(coin.PercentChange24h)} {coin.CurrentPrice.ToShortString()}$ ({GetSign(coin.PercentChange24h)}{Math.Round(coin.PercentChange24h, 2)}%)
+                                   `MarketCap:` {coin.MarketCap.ToShortString()}$
+                                   `Supply:` {(coin.CirculatingSupply?.ToShortString() ?? "?")} / {(coin.TotalSupply?.ToShortString() ?? "?")}
+                                   """,
+                                  inline: false);
+                          }
+                      }
+
+                      return embed;
+                  })
+                  .CurrentPage(page)
+                  .AddFooter(false)
+                  .SendAsync();
+        }
+        
+        private static string GetArrowEmoji(decimal value)
+            => value > 0 ? "▲" : "▼";
+
+        private static string GetSign(decimal value)
+            => value >= 0 ? "+" : "";
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/CryptoService.cs b/src/EllieBot/Modules/Searches/Crypto/CryptoService.cs
new file mode 100644
index 0000000..0ffd422
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/CryptoService.cs
@@ -0,0 +1,266 @@
+#nullable enable
+using EllieBot.Modules.Searches.Common;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+using System.Globalization;
+using System.Net.Http.Json;
+using System.Text.Json.Serialization;
+using System.Xml;
+using Color = SixLabors.ImageSharp.Color;
+
+namespace EllieBot.Modules.Searches.Services;
+
+public class CryptoService : IEService
+{
+    private readonly IBotCache _cache;
+    private readonly IHttpClientFactory _httpFactory;
+    private readonly IBotCredentials _creds;
+
+    private readonly SemaphoreSlim _getCryptoLock = new(1, 1);
+
+    public CryptoService(IBotCache cache, IHttpClientFactory httpFactory, IBotCredentials creds)
+    {
+        _cache = cache;
+        _httpFactory = httpFactory;
+        _creds = creds;
+    }
+
+    private PointF[] GetSparklinePointsFromSvgText(string svgText)
+    {
+        var xml = new XmlDocument();
+        xml.LoadXml(svgText);
+
+        var gElement = xml["svg"]?["g"];
+        if (gElement is null)
+            return Array.Empty<PointF>();
+
+        Span<PointF> points = new PointF[gElement.ChildNodes.Count];
+        var cnt = 0;
+
+        bool GetValuesFromAttributes(
+            XmlAttributeCollection attrs,
+            out float x1,
+            out float y1,
+            out float x2,
+            out float y2)
+        {
+            (x1, y1, x2, y2) = (0, 0, 0, 0);
+            return attrs["x1"]?.Value is string x1Str
+                   && float.TryParse(x1Str, NumberStyles.Any, CultureInfo.InvariantCulture, out x1)
+                   && attrs["y1"]?.Value is string y1Str
+                   && float.TryParse(y1Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y1)
+                   && attrs["x2"]?.Value is string x2Str
+                   && float.TryParse(x2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out x2)
+                   && attrs["y2"]?.Value is string y2Str
+                   && float.TryParse(y2Str, NumberStyles.Any, CultureInfo.InvariantCulture, out y2);
+        }
+
+        foreach (XmlElement x in gElement.ChildNodes)
+        {
+            if (x.Name != "line")
+                continue;
+
+            if (GetValuesFromAttributes(x.Attributes, out var x1, out var y1, out var x2, out var y2))
+            {
+                points[cnt++] = new(x1, y1);
+                // this point will be set twice to the same value
+                // on all points except the last one
+                if (cnt + 1 < points.Length)
+                    points[cnt + 1] = new(x2, y2);
+            }
+        }
+
+        if (cnt == 0)
+            return Array.Empty<PointF>();
+
+        return points.Slice(0, cnt).ToArray();
+    }
+
+    private SixLabors.ImageSharp.Image<Rgba32> GenerateSparklineChart(PointF[] points, bool up)
+    {
+        const int width = 164;
+        const int height = 48;
+
+        var img = new Image<Rgba32>(width, height, Color.Transparent);
+        var color = up
+            ? Color.Green
+            : Color.FromRgb(220, 0, 0);
+
+        img.Mutate(x =>
+        {
+            x.DrawLine(color, 2, points);
+        });
+
+        return img;
+    }
+
+    public async Task<(CmcResponseData? Data, CmcResponseData? Nearest)> GetCryptoData(string name)
+    {
+        if (string.IsNullOrWhiteSpace(name))
+            return (null, null);
+
+        name = name.ToUpperInvariant();
+        var cryptos = await GetCryptoDataInternal();
+
+        if (cryptos is null or { Count: 0 })
+            return (null, null);
+
+        var crypto = cryptos.FirstOrDefault(x
+            => x.Slug.ToUpperInvariant() == name
+               || x.Name.ToUpperInvariant() == name
+               || x.Symbol.ToUpperInvariant() == name);
+
+        if (crypto is not null)
+            return (crypto, null);
+
+
+        var nearest = cryptos
+                      .Select(elem => (Elem: elem,
+                          Distance: elem.Name.ToUpperInvariant().LevenshteinDistance(name)))
+                      .OrderBy(x => x.Distance)
+                      .FirstOrDefault(x => x.Distance <= 2);
+
+        return (null, nearest.Elem);
+    }
+
+    public async Task<List<CmcResponseData>?> GetCryptoDataInternal()
+    {
+        await _getCryptoLock.WaitAsync();
+        try
+        {
+            var data = await _cache.GetOrAddAsync(new("ellie:crypto_data"),
+                async () =>
+                {
+                    try
+                    {
+                        using var http = _httpFactory.CreateClient();
+                        var data = await http.GetFromJsonAsync<CryptoResponse>(
+                            "https://pro-api.coinmarketcap.com/v1/cryptocurrency/listings/latest?"
+                            + $"CMC_PRO_API_KEY={_creds.CoinmarketcapApiKey}"
+                            + "&start=1"
+                            + "&limit=5000"
+                            + "&convert=USD");
+
+                        return data;
+                    }
+                    catch (Exception ex)
+                    {
+                        Log.Error(ex, "Error getting crypto data: {Message}", ex.Message);
+                        return default;
+                    }
+                },
+                TimeSpan.FromHours(2));
+
+            if (data is null)
+                return default;
+
+            return data.Data;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Error retreiving crypto data: {Message}", ex.Message);
+            return default;
+        }
+        finally
+        {
+            _getCryptoLock.Release();
+        }
+    }
+
+    private TypedKey<byte[]> GetSparklineKey(int id)
+        => new($"crypto:sparkline:{id}");
+
+    public async Task<Stream?> GetSparklineAsync(int id, bool up)
+    {
+        try
+        {
+            var bytes = await _cache.GetOrAddAsync(GetSparklineKey(id),
+                async () =>
+                {
+                    // if it fails, generate a new one
+                    var points = await DownloadSparklinePointsAsync(id);
+                    var sparkline = GenerateSparklineChart(points, up);
+
+                    using var stream = await sparkline.ToStreamAsync();
+                    return stream.ToArray();
+                },
+                TimeSpan.FromHours(1));
+
+            if (bytes is { Length: > 0 })
+            {
+                return bytes.ToStream();
+            }
+
+            return default;
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex,
+                "Exception occurred while downloading sparkline points: {ErrorMessage}",
+                ex.Message);
+            return default;
+        }
+    }
+
+    private async Task<PointF[]> DownloadSparklinePointsAsync(int id)
+    {
+        using var http = _httpFactory.CreateClient();
+        var str = await http.GetStringAsync(
+            $"https://s3.coinmarketcap.com/generated/sparklines/web/7d/usd/{id}.svg");
+        var points = GetSparklinePointsFromSvgText(str);
+        return points;
+    }
+
+    private static TypedKey<IReadOnlyCollection<GeckoCoinsResult>> GetTopCoinsKey()
+        => new($"crypto:top_coins");
+
+    public async Task<IReadOnlyCollection<GeckoCoinsResult>?> GetTopCoins(int page)
+    {
+        if (page >= 25)
+            page = 24;
+
+        using var http = _httpFactory.CreateClient();
+
+        http.AddFakeHeaders();
+
+        var result = await _cache.GetOrAddAsync<IReadOnlyCollection<GeckoCoinsResult>>(GetTopCoinsKey(),
+            async () => await http.GetFromJsonAsync<List<GeckoCoinsResult>>(
+                            "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=250")
+                        ?? [],
+            expiry: TimeSpan.FromHours(1));
+
+        return result!.Skip(page * 10).Take(10).ToList();
+    }
+}
+
+public sealed class GeckoCoinsResult
+{
+    [JsonPropertyName("id")]
+    public required string Id { get; init; }
+
+    [JsonPropertyName("name")]
+    public required string Name { get; init; }
+
+    [JsonPropertyName("symbol")]
+    public required string Symbol { get; init; }
+
+    [JsonPropertyName("current_price")]
+    public required decimal CurrentPrice { get; init; }
+
+    [JsonPropertyName("price_change_percentage_24h")]
+    public required decimal PercentChange24h { get; init; }
+
+    [JsonPropertyName("market_cap")]
+    public required decimal MarketCap { get; init; }
+
+    [JsonPropertyName("circulating_supply")]
+    public required decimal? CirculatingSupply { get; init; }
+
+    [JsonPropertyName("total_supply")]
+    public required decimal? TotalSupply { get; init; }
+
+    [JsonPropertyName("market_cap_rank")]
+    public required int MarketCapRank { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs b/src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs
new file mode 100644
index 0000000..9b000bd
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/DefaultStockDataService.cs
@@ -0,0 +1,112 @@
+using AngleSharp;
+using CsvHelper;
+using CsvHelper.Configuration;
+using System.Globalization;
+using System.Text.Json;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class DefaultStockDataService : IStockDataService, IEService
+{
+    private readonly IHttpClientFactory _httpClientFactory;
+
+    public DefaultStockDataService(IHttpClientFactory httpClientFactory)
+        => _httpClientFactory = httpClientFactory;
+
+    public async Task<StockData?> GetStockDataAsync(string query)
+    {
+        try
+        {
+            if (!query.IsAlphaNumeric())
+                return default;
+
+            using var http = _httpClientFactory.CreateClient();
+
+            var quoteHtmlPage = $"https://finance.yahoo.com/quote/{query.ToUpperInvariant()}";
+
+            var config = Configuration.Default.WithDefaultLoader();
+            using var document = await BrowsingContext.New(config).OpenAsync(quoteHtmlPage);
+
+            var tickerName = document.QuerySelector("div.top > .left > .container > h1")
+                                     ?.TextContent;
+            
+            if (tickerName is null)
+                return default;
+            
+            var marketcap = document
+                            .QuerySelector("li > span > fin-streamer[data-field='marketCap']")
+                            ?.TextContent;
+
+
+            var volume = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketVolume']")
+                                 ?.TextContent;
+
+            var close = document.QuerySelector("li > span > fin-streamer[data-field='regularMarketPreviousClose']")
+                                ?.TextContent
+                        ?? "0";
+
+            var price = document.QuerySelector("fin-streamer.livePrice > span")
+                                ?.TextContent
+                        ?? "0";
+
+            return new()
+            {
+                Name = tickerName,
+                Symbol = query,
+                Price = double.Parse(price, NumberStyles.Any, CultureInfo.InvariantCulture),
+                Close = double.Parse(close, NumberStyles.Any, CultureInfo.InvariantCulture),
+                MarketCap = marketcap,
+                DailyVolume = (long)double.Parse(volume ?? "0", NumberStyles.Any, CultureInfo.InvariantCulture),
+            };
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "Error getting stock data: {ErrorMessage}", ex.ToString());
+            return default;
+        }
+    }
+
+    public async Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query)
+    {
+        if (string.IsNullOrWhiteSpace(query))
+            throw new ArgumentNullException(nameof(query));
+
+        query = Uri.EscapeDataString(query);
+
+        using var http = _httpClientFactory.CreateClient();
+
+        var res = await http.GetStringAsync(
+            "https://finance.yahoo.com/_finance_doubledown/api/resource/searchassist"
+            + $";searchTerm={query}"
+            + "?device=console");
+
+        var data = JsonSerializer.Deserialize<YahooFinanceSearchResponse>(res);
+
+        if (data is null or { Items: null })
+            return Array.Empty<SymbolData>();
+
+        return data.Items
+                   .Where(x => x.Type == "S")
+                   .Select(x => new SymbolData(x.Symbol, x.Name))
+                   .ToList();
+    }
+
+    private static CsvConfiguration _csvConfig = new(CultureInfo.InvariantCulture);
+
+    public async Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query)
+    {
+        using var http = _httpClientFactory.CreateClient();
+        await using var resStream = await http.GetStreamAsync(
+            $"https://query1.finance.yahoo.com/v7/finance/download/{query}"
+            + $"?period1={DateTime.UtcNow.Subtract(30.Days()).ToTimestamp()}"
+            + $"&period2={DateTime.UtcNow.ToTimestamp()}"
+            + "&interval=1d");
+
+        using var textReader = new StreamReader(resStream);
+        using var csv = new CsvReader(textReader, _csvConfig);
+        var records = csv.GetRecords<YahooFinanceCandleData>().ToArray();
+
+        return records
+            .Map(static x => new CandleData(x.Open, x.Close, x.High, x.Low, x.Volume));
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs b/src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs
new file mode 100644
index 0000000..97da2da
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/Drawing/CandleDrawingData.cs
@@ -0,0 +1,12 @@
+using SixLabors.ImageSharp;
+
+namespace EllieBot.Modules.Searches;
+
+/// <summary>
+/// All data required to draw a candle
+/// </summary>
+/// <param name="IsGreen">Whether the candle is green</param>
+/// <param name="BodyRect">Rectangle for the body</param>
+/// <param name="High">High line point</param>
+/// <param name="Low">Low line point</param>
+public record CandleDrawingData(bool IsGreen, RectangleF BodyRect, PointF High, PointF Low);
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs b/src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs
new file mode 100644
index 0000000..9676e88
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/Drawing/IStockChartDrawingService.cs
@@ -0,0 +1,8 @@
+namespace EllieBot.Modules.Searches;
+
+public interface IStockChartDrawingService
+{
+    Task<ImageData?> GenerateSparklineAsync(IReadOnlyCollection<CandleData> series);
+    Task<ImageData?> GenerateCombinedChartAsync(IReadOnlyCollection<CandleData> series);
+    Task<ImageData?> GenerateCandleChartAsync(IReadOnlyCollection<CandleData> series);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs b/src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs
new file mode 100644
index 0000000..95aa51f
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/Drawing/ImagesharpStockChartDrawingService.cs
@@ -0,0 +1,200 @@
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+using System.Runtime.CompilerServices;
+using Color = SixLabors.ImageSharp.Color;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class ImagesharpStockChartDrawingService : IStockChartDrawingService, IEService
+{
+    private const int WIDTH = 300;
+    private const int HEIGHT = 100;
+    private const decimal MAX_HEIGHT = HEIGHT * 0.8m;
+
+    private static readonly Rgba32 _backgroundColor = Rgba32.ParseHex("17181E");
+    private static readonly Rgba32 _lineGuideColor = Rgba32.ParseHex("212125");
+    private static readonly Rgba32 _sparklineColor = Rgba32.ParseHex("2961FC");
+    private static readonly Rgba32 _greenBrush = Rgba32.ParseHex("26A69A");
+    private static readonly Rgba32 _redBrush = Rgba32.ParseHex("EF5350");
+
+    private static float GetNormalizedPoint(decimal max, decimal point, decimal range)
+        => (float)((MAX_HEIGHT * ((max - point) / range)) + HeightOffset());
+        
+    private PointF[] GetSparklinePointsInternal(IReadOnlyCollection<CandleData> series)
+    {
+        var candleStep = WIDTH / (series.Count + 1);
+        var max = series.Max(static x => x.High);
+        var min = series.Min(static x => x.Low);
+    
+        var range = max - min;
+    
+        var points = new PointF[series.Count];
+    
+        var i = 0;
+        foreach (var candle in series)
+        {
+            var x = candleStep * (i + 1);
+
+            var y = GetNormalizedPoint(max, candle.Close, range);
+            points[i++] = new(x, y);
+        }
+
+        return points;
+    }
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private static decimal HeightOffset()
+        => (HEIGHT - MAX_HEIGHT) / 2m;
+
+    [MethodImpl(MethodImplOptions.AggressiveInlining)]
+    private static Image<Rgba32> CreateCanvasInternal()
+        => new Image<Rgba32>(WIDTH, HEIGHT, _backgroundColor);
+
+    private CandleDrawingData[] GetChartDrawingDataInternal(IReadOnlyCollection<CandleData> series)
+    {
+        var candleMargin = 2;
+        var candleStep = (WIDTH - (candleMargin * series.Count)) / (series.Count + 1);
+        var max = series.Max(static x => x.High);
+        var min = series.Min(static x => x.Low);
+
+        var range = max - min;
+
+        var drawData = new CandleDrawingData[series.Count];
+
+        var candleWidth = candleStep;
+        
+        var i = 0;
+        foreach (var candle in series)
+        {
+            var offsetX = (i - 1) * candleMargin; 
+            var x = (candleStep * (i + 1)) + offsetX;
+            var yOpen = GetNormalizedPoint(max, candle.Open, range);
+            var yClose = GetNormalizedPoint(max, candle.Close, range);
+            var y = candle.Open > candle.Close
+                ? yOpen
+                : yClose;
+
+            var sizeH = Math.Abs(yOpen - yClose);
+
+            var high = GetNormalizedPoint(max, candle.High, range);
+            var low = GetNormalizedPoint(max, candle.Low, range);
+            drawData[i] = new(candle.Open < candle.Close,
+                new(x, y, candleWidth, sizeH),
+                new(x + (candleStep / 2), high),
+                new(x + (candleStep / 2), low));
+            ++i;
+        }
+
+        return drawData;
+    }
+
+    private void DrawChartData(Image<Rgba32> image, CandleDrawingData[] drawData)
+        => image.Mutate(ctx =>
+        {
+            foreach (var data in drawData)
+                ctx.DrawLine(data.IsGreen
+                        ? _greenBrush
+                        : _redBrush,
+                    1,
+                    data.High,
+                    data.Low);
+
+
+            foreach (var data in drawData)
+                ctx.Fill(data.IsGreen
+                        ? _greenBrush
+                        : _redBrush,
+                    data.BodyRect);
+        });
+
+    private void DrawLineGuides(Image<Rgba32> image, IReadOnlyCollection<CandleData> series)
+    {
+        var max = series.Max(x => x.High);
+        var min = series.Min(x => x.Low);
+
+        var step = (max - min) / 5;
+
+        var lines = new float[6];
+        
+        for (var i = 0; i < 6; i++)
+        {
+            var y = GetNormalizedPoint(max, min + (step * i), max - min);
+            lines[i] = y;
+        }
+
+        image.Mutate(ctx =>
+        {
+            // draw guides
+            foreach (var y in lines)
+                ctx.DrawLine(_lineGuideColor, 1, new PointF(0, y), new PointF(WIDTH, y));
+            
+            // // draw min and max price on the chart
+            // ctx.DrawText(min.ToString(CultureInfo.InvariantCulture),
+            //     SystemFonts.CreateFont("Arial", 5),
+            //     Color.White,
+            //     new PointF(0, (float)HeightOffset() - 5)
+            // );
+            //
+            // ctx.DrawText(max.ToString("N1", CultureInfo.InvariantCulture),
+            //     SystemFonts.CreateFont("Arial", 5),
+            //     Color.White,
+            //     new PointF(0,  HEIGHT - (float)HeightOffset())
+            // );
+        });
+    }
+    
+    public Task<ImageData?> GenerateSparklineAsync(IReadOnlyCollection<CandleData> series)
+    {
+        if (series.Count == 0)
+            return Task.FromResult<ImageData?>(default);
+
+        using var image = CreateCanvasInternal();
+
+        var points = GetSparklinePointsInternal(series);
+        
+        image.Mutate(ctx =>
+        {
+            ctx.DrawLine(_sparklineColor, 2, points);
+        });
+    
+        return Task.FromResult<ImageData?>(new("png", image.ToStream()));
+    }
+
+    public Task<ImageData?> GenerateCombinedChartAsync(IReadOnlyCollection<CandleData> series)
+    {
+        if (series.Count == 0)
+            return Task.FromResult<ImageData?>(default);
+
+        using var image = CreateCanvasInternal();
+        
+        DrawLineGuides(image, series);
+        
+        var chartData = GetChartDrawingDataInternal(series);
+        DrawChartData(image, chartData);
+
+        var points = GetSparklinePointsInternal(series);
+        image.Mutate(ctx =>
+        {
+            ctx.DrawLine(Color.ParseHex("00FFFFAA"), 1, points);
+        });
+
+        return Task.FromResult<ImageData?>(new("png", image.ToStream()));
+    }
+    
+    public Task<ImageData?> GenerateCandleChartAsync(IReadOnlyCollection<CandleData> series)
+    {
+        if (series.Count == 0)
+            return Task.FromResult<ImageData?>(default);
+
+        using var image = CreateCanvasInternal();
+
+        DrawLineGuides(image, series);
+        
+        var drawData = GetChartDrawingDataInternal(series);
+        DrawChartData(image, drawData);
+
+        return Task.FromResult<ImageData?>(new("png", image.ToStream()));
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs b/src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs
new file mode 100644
index 0000000..5f778e8
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/IStockDataService.cs
@@ -0,0 +1,8 @@
+namespace EllieBot.Modules.Searches;
+
+public interface IStockDataService
+{
+    public Task<StockData?> GetStockDataAsync(string symbol);
+    Task<IReadOnlyCollection<SymbolData>> SearchSymbolAsync(string query);
+    Task<IReadOnlyCollection<CandleData>> GetCandleDataAsync(string query);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs
new file mode 100644
index 0000000..97d1a17
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/_common/CandleData.cs
@@ -0,0 +1,8 @@
+namespace EllieBot.Modules.Searches;
+
+public record CandleData(
+    decimal Open,
+    decimal Close,
+    decimal High,
+    decimal Low,
+    long Volume);
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs
new file mode 100644
index 0000000..d49d1ba
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/_common/ImageData.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.Searches;
+
+public record ImageData(string Extension, Stream FileData) : IAsyncDisposable
+{
+    public ValueTask DisposeAsync()
+        => FileData.DisposeAsync();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs b/src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs
new file mode 100644
index 0000000..13ea277
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/_common/QuoteResponse.cs
@@ -0,0 +1,43 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public class QuoteResponse
+{
+    public class ResultModel
+    {
+        [JsonPropertyName("longName")]
+        public string LongName { get; set; }
+
+        [JsonPropertyName("regularMarketPrice")]
+        public double RegularMarketPrice { get; set; }
+
+        [JsonPropertyName("regularMarketPreviousClose")]
+        public double RegularMarketPreviousClose { get; set; }
+
+        [JsonPropertyName("fullExchangeName")]
+        public string FullExchangeName { get; set; }
+
+        [JsonPropertyName("averageDailyVolume10Day")]
+        public int AverageDailyVolume10Day { get; set; }
+
+        [JsonPropertyName("fiftyDayAverageChangePercent")]
+        public double FiftyDayAverageChangePercent { get; set; }
+
+        [JsonPropertyName("twoHundredDayAverageChangePercent")]
+        public double TwoHundredDayAverageChangePercent { get; set; }
+
+        [JsonPropertyName("marketCap")]
+        public long MarketCap { get; set; }
+
+        [JsonPropertyName("symbol")]
+        public string Symbol { get; set; }
+    }
+    
+    [JsonPropertyName("result")]
+    public List<ResultModel> Result { get; set; }
+
+    [JsonPropertyName("error")]
+    public object Error { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs
new file mode 100644
index 0000000..dfb99c7
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/_common/StockData.cs
@@ -0,0 +1,15 @@
+#nullable disable
+namespace EllieBot.Modules.Searches;
+
+public class StockData
+{
+    public string Name { get; set; }
+    public string Symbol { get; set; }
+    public double Price { get; set; }
+    public string MarketCap { get; set; }
+    public double Close { get; set; }
+    public double Change50d { get; set; }
+    public double Change200d { get; set; }
+    public long DailyVolume { get; set; }
+    public string Exchange { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs
new file mode 100644
index 0000000..01ef65d
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/_common/SymbolData.cs
@@ -0,0 +1,3 @@
+namespace EllieBot.Modules.Searches;
+
+public record SymbolData(string Symbol, string Description);
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs
new file mode 100644
index 0000000..619bdc3
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceCandleData.cs
@@ -0,0 +1,12 @@
+namespace EllieBot.Modules.Searches;
+
+public class YahooFinanceCandleData
+{
+    public DateTime Date { get; set; }
+    public decimal Open { get; set; }
+    public decimal High { get; set; }
+    public decimal Low { get; set; }
+    public decimal Close { get; set; }
+    public decimal AdjClose { get; set; }
+    public long Volume { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs
new file mode 100644
index 0000000..168dd82
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponse.cs
@@ -0,0 +1,19 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public class YahooFinanceSearchResponse
+{
+    [JsonPropertyName("suggestionTitleAccessor")]
+    public string SuggestionTitleAccessor { get; set; }
+
+    [JsonPropertyName("suggestionMeta")]
+    public List<string> SuggestionMeta { get; set; }
+
+    [JsonPropertyName("hiConf")]
+    public bool HiConf { get; set; }
+
+    [JsonPropertyName("items")]
+    public List<YahooFinanceSearchResponseItem> Items { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs
new file mode 100644
index 0000000..e8eaa9f
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooFinanceSearchResponseItem.cs
@@ -0,0 +1,25 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public class YahooFinanceSearchResponseItem
+{
+    [JsonPropertyName("symbol")]
+    public string Symbol { get; set; }
+
+    [JsonPropertyName("name")]
+    public string Name { get; set; }
+
+    [JsonPropertyName("exch")]
+    public string Exch { get; set; }
+
+    [JsonPropertyName("type")]
+    public string Type { get; set; }
+
+    [JsonPropertyName("exchDisp")]
+    public string ExchDisp { get; set; }
+
+    [JsonPropertyName("typeDisp")]
+    public string TypeDisp { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs b/src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs
new file mode 100644
index 0000000..4efc94f
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Crypto/_common/YahooQueryModel.cs
@@ -0,0 +1,9 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public class YahooQueryModel
+{
+    [JsonPropertyName("quoteResponse")]
+    public QuoteResponse QuoteResponse { get; set; } = null!;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs b/src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs
new file mode 100644
index 0000000..316fc73
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Feeds/FeedCommands.cs
@@ -0,0 +1,148 @@
+using CodeHollow.FeedReader;
+using EllieBot.Modules.Searches.Services;
+using System.Text.RegularExpressions;
+
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches
+{
+    [Group]
+    public partial class FeedCommands : EllieModule<FeedsService>
+    {
+        private static readonly Regex _ytChannelRegex =
+            new(@"youtube\.com\/(?:c\/|channel\/|user\/)?(?<channelid>[a-zA-Z0-9\-_]{1,})");
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        [Priority(1)]
+        public Task YtUploadNotif(string url, [Leftover] string? message = null)
+            => YtUploadNotif(url, null, message);
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        [Priority(2)]
+        public Task YtUploadNotif(string url, ITextChannel? channel = null, [Leftover] string? message = null)
+        {
+            var m = _ytChannelRegex.Match(url);
+            if (!m.Success)
+                return Response().Error(strs.invalid_input).SendAsync();
+
+            channel ??= ctx.Channel as ITextChannel;
+
+            if (!((IGuildUser)ctx.User).GetPermissions(channel).MentionEveryone)
+                message = message?.SanitizeAllMentions();
+
+            var channelId = m.Groups["channelid"].Value;
+
+            return Feed($"https://www.youtube.com/feeds/videos.xml?channel_id={channelId}", channel, message);
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        [Priority(0)]
+        public Task Feed(string url, [Leftover] string? message = null)
+            => Feed(url, null, message);
+
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        [Priority(1)]
+        public async Task Feed(string url, ITextChannel? channel = null, [Leftover] string? message = null)
+        {
+            if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)
+                || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
+            {
+                await Response().Error(strs.feed_invalid_url).SendAsync();
+                return;
+            }
+
+            channel ??= (ITextChannel)ctx.Channel;
+            
+            if (!((IGuildUser)ctx.User).GetPermissions(channel).MentionEveryone)
+                message = message?.SanitizeAllMentions();
+
+            try
+            {
+                await FeedReader.ReadAsync(url);
+            }
+            catch (Exception ex)
+            {
+                Log.Information(ex, "Unable to get feeds from that url");
+                await Response().Error(strs.feed_cant_parse).SendAsync();
+                return;
+            }
+
+            if (ctx.User is not IGuildUser gu || !gu.GuildPermissions.Administrator)
+                message = message?.SanitizeMentions(true);
+
+            var result = _service.AddFeed(ctx.Guild.Id, channel.Id, url, message);
+            if (result == FeedAddResult.Success)
+            {
+                await Response().Confirm(strs.feed_added).SendAsync();
+                return;
+            }
+
+            if (result == FeedAddResult.Duplicate)
+            {
+                await Response().Error(strs.feed_duplicate).SendAsync();
+                return;
+            }
+
+            if (result == FeedAddResult.LimitReached)
+            {
+                await Response().Error(strs.feed_limit_reached).SendAsync();
+                return;
+            }
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        public async Task FeedRemove(int index)
+        {
+            if (_service.RemoveFeed(ctx.Guild.Id, --index))
+                await Response().Confirm(strs.feed_removed).SendAsync();
+            else
+                await Response().Error(strs.feed_out_of_range).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        public async Task FeedList(int page = 1)
+        {
+            if (--page < 0)
+                return;
+            
+            var feeds = _service.GetFeeds(ctx.Guild.Id);
+
+            if (!feeds.Any())
+            {
+                await Response()
+                      .Embed(_sender.CreateEmbed().WithOkColor().WithDescription(GetText(strs.feed_no_feed)))
+                      .SendAsync();
+                return;
+            }
+
+            await Response()
+                  .Paginated()
+                  .Items(feeds)
+                  .PageSize(10)
+                  .CurrentPage(page)
+                  .Page((items, cur) =>
+                  {
+                      var embed = _sender.CreateEmbed().WithOkColor();
+                      var i = 0;
+                      var fs = string.Join("\n",
+                          items.Select(x => $"`{(cur * 10) + ++i}.` <#{x.ChannelId}> {x.Url}"));
+
+                      return embed.WithDescription(fs);
+                  })
+                  .SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Feeds/FeedsService.cs b/src/EllieBot/Modules/Searches/Feeds/FeedsService.cs
new file mode 100644
index 0000000..d08ec0a
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Feeds/FeedsService.cs
@@ -0,0 +1,309 @@
+#nullable disable
+using CodeHollow.FeedReader;
+using CodeHollow.FeedReader.Feeds;
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Searches.Services;
+
+public class FeedsService : IEService
+{
+    private readonly DbService _db;
+    private readonly ConcurrentDictionary<string, List<FeedSub>> _subs;
+    private readonly DiscordSocketClient _client;
+    private readonly IMessageSenderService _sender;
+
+    private readonly ConcurrentDictionary<string, DateTime> _lastPosts = new();
+    private readonly Dictionary<string, uint> _errorCounters = new();
+
+    public FeedsService(
+        IBot bot,
+        DbService db,
+        DiscordSocketClient client,
+        IMessageSenderService sender)
+    {
+        _db = db;
+
+        using (var uow = db.GetDbContext())
+        {
+            var guildConfigIds = bot.AllGuildConfigs.Select(x => x.Id).ToList();
+            _subs = uow.Set<GuildConfig>()
+                       .AsQueryable()
+                       .Where(x => guildConfigIds.Contains(x.Id))
+                       .Include(x => x.FeedSubs)
+                       .ToList()
+                       .SelectMany(x => x.FeedSubs)
+                       .GroupBy(x => x.Url.ToLower())
+                       .ToDictionary(x => x.Key, x => x.ToList())
+                       .ToConcurrent();
+        }
+
+        _client = client;
+        _sender = sender;
+
+        _ = Task.Run(TrackFeeds);
+    }
+
+    private void ClearErrors(string url)
+        => _errorCounters.Remove(url);
+
+    private async Task<uint> AddError(string url, List<int> ids)
+    {
+        try
+        {
+            var newValue = _errorCounters[url] = _errorCounters.GetValueOrDefault(url) + 1;
+
+            if (newValue >= 100)
+            {
+                // remove from db
+                await using var ctx = _db.GetDbContext();
+                await ctx.GetTable<FeedSub>()
+                         .DeleteAsync(x => ids.Contains(x.Id));
+
+                // remove from the local cache
+                _subs.TryRemove(url, out _);
+
+                // reset the error counter
+                ClearErrors(url);
+            }
+
+            return newValue;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Error adding rss errors...");
+            return 0;
+        }
+    }
+
+    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()
+    {
+        while (true)
+        {
+            var allSendTasks = new List<Task>(_subs.Count);
+            foreach (var kvp in _subs)
+            {
+                if (kvp.Value.Count == 0)
+                    continue;
+
+                var rssUrl = kvp.Value.First().Url;
+                try
+                {
+                    var feed = await FeedReader.ReadAsync(rssUrl);
+
+                    var items = new List<(FeedItem Item, DateTime LastUpdate)>();
+                    foreach (var item in feed.Items)
+                    {
+                        var pubDate = GetPubDate(item);
+
+                        if (pubDate is null)
+                            continue;
+
+                        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))
+                    {
+                        lastFeedUpdate = _lastPosts[kvp.Key] = items[0].LastUpdate;
+                    }
+
+                    for (var index = 1; index <= items.Count; index++)
+                    {
+                        var (feedItem, itemUpdateDate) = items[^index];
+                        if (itemUpdateDate <= lastFeedUpdate)
+                            continue;
+
+                        var embed = _sender.CreateEmbed().WithFooter(rssUrl);
+
+                        _lastPosts[kvp.Key] = itemUpdateDate;
+
+                        var link = feedItem.SpecificItem.Link;
+                        if (!string.IsNullOrWhiteSpace(link) && Uri.IsWellFormedUriString(link, UriKind.Absolute))
+                            embed.WithUrl(link);
+
+                        var title = string.IsNullOrWhiteSpace(feedItem.Title) ? "-" : feedItem.Title;
+
+                        var gotImage = false;
+                        if (feedItem.SpecificItem is MediaRssFeedItem mrfi
+                            && (mrfi.Enclosure?.MediaType?.StartsWith("image/") ?? false))
+                        {
+                            var imgUrl = mrfi.Enclosure.Url;
+                            if (!string.IsNullOrWhiteSpace(imgUrl)
+                                && Uri.IsWellFormedUriString(imgUrl, UriKind.Absolute))
+                            {
+                                embed.WithImageUrl(imgUrl);
+                                gotImage = true;
+                            }
+                        }
+
+                        if (!gotImage && feedItem.SpecificItem is AtomFeedItem afi)
+                        {
+                            var previewElement = afi.Element.Elements()
+                                                    .FirstOrDefault(x => x.Name.LocalName == "preview");
+
+                            if (previewElement is null)
+                            {
+                                previewElement = afi.Element.Elements()
+                                                    .FirstOrDefault(x => x.Name.LocalName == "thumbnail");
+                            }
+
+                            if (previewElement is not null)
+                            {
+                                var urlAttribute = previewElement.Attribute("url");
+                                if (urlAttribute is not null
+                                    && !string.IsNullOrWhiteSpace(urlAttribute.Value)
+                                    && Uri.IsWellFormedUriString(urlAttribute.Value, UriKind.Absolute))
+                                {
+                                    embed.WithImageUrl(urlAttribute.Value);
+                                    gotImage = true;
+                                }
+                            }
+                        }
+
+                        embed.WithTitle(title.TrimTo(256));
+
+                        var desc = feedItem.Description?.StripHtml();
+                        if (!string.IsNullOrWhiteSpace(feedItem.Description))
+                            embed.WithDescription(desc.TrimTo(2048));
+
+
+                        var tasks = new List<Task>();
+
+                        foreach (var val in kvp.Value)
+                        {
+                            var ch = _client.GetGuild(val.GuildConfig.GuildId).GetTextChannel(val.ChannelId);
+
+                            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
+                        ClearErrors(rssUrl);
+                    }
+                }
+                catch (Exception ex)
+                {
+                    var errorCount = await AddError(rssUrl, kvp.Value.Select(x => x.Id).ToList());
+
+                    Log.Warning("An error occured while getting rss stream ({ErrorCount} / 100) {RssFeed}"
+                                + "\n {Message}",
+                        errorCount,
+                        rssUrl,
+                        $"[{ex.GetType().Name}]: {ex.Message}");
+                }
+            }
+
+            await Task.WhenAll(Task.WhenAll(allSendTasks), Task.Delay(30000));
+        }
+    }
+
+    public List<FeedSub> GetFeeds(ulong guildId)
+    {
+        using var uow = _db.GetDbContext();
+        return uow.GuildConfigsForId(guildId, set => set.Include(x => x.FeedSubs))
+                  .FeedSubs.OrderBy(x => x.Id)
+                  .ToList();
+    }
+
+    public FeedAddResult AddFeed(
+        ulong guildId,
+        ulong channelId,
+        string rssFeed,
+        string message)
+    {
+        ArgumentNullException.ThrowIfNull(rssFeed, nameof(rssFeed));
+
+        var fs = new FeedSub
+        {
+            ChannelId = channelId,
+            Url = rssFeed.Trim(),
+            Message = message
+        };
+
+        using var uow = _db.GetDbContext();
+        var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FeedSubs));
+
+        if (gc.FeedSubs.Any(x => x.Url.ToLower() == fs.Url.ToLower()))
+            return FeedAddResult.Duplicate;
+        if (gc.FeedSubs.Count >= 10)
+            return FeedAddResult.LimitReached;
+
+        gc.FeedSubs.Add(fs);
+        uow.SaveChanges();
+        //adding all, in case bot wasn't on this guild when it started
+        foreach (var feed in gc.FeedSubs)
+        {
+            _subs.AddOrUpdate(feed.Url.ToLower(),
+                [feed],
+                (_, old) =>
+                {
+                    old.Add(feed);
+                    return old;
+                });
+        }
+
+        return FeedAddResult.Success;
+    }
+
+    public bool RemoveFeed(ulong guildId, int index)
+    {
+        if (index < 0)
+            return false;
+
+        using var uow = _db.GetDbContext();
+        var items = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FeedSubs))
+                       .FeedSubs.OrderBy(x => x.Id)
+                       .ToList();
+
+        if (items.Count <= index)
+            return false;
+        var toRemove = items[index];
+        _subs.AddOrUpdate(toRemove.Url.ToLower(),
+            [],
+            (_, old) =>
+            {
+                old.Remove(toRemove);
+                return old;
+            });
+        uow.Remove(toRemove);
+        uow.SaveChanges();
+
+        return true;
+    }
+}
+
+public enum FeedAddResult
+{
+    Success,
+    LimitReached,
+    Invalid,
+    Duplicate,
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/JokeCommands.cs b/src/EllieBot/Modules/Searches/JokeCommands.cs
new file mode 100644
index 0000000..d41c50a
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/JokeCommands.cs
@@ -0,0 +1,53 @@
+#nullable disable
+using EllieBot.Modules.Searches.Services;
+
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches
+{
+    [Group]
+    public partial class JokeCommands : EllieModule<SearchesService>
+    {
+        [Cmd]
+        public async Task Yomama()
+            => await Response().Confirm(await _service.GetYomamaJoke()).SendAsync();
+
+        [Cmd]
+        public async Task Randjoke()
+        {
+            var (setup, punchline) = await _service.GetRandomJoke();
+            await Response().Confirm(setup, punchline).SendAsync();
+        }
+
+        [Cmd]
+        public async Task ChuckNorris()
+            => await Response().Confirm(await _service.GetChuckNorrisJoke()).SendAsync();
+
+        [Cmd]
+        public async Task WowJoke()
+        {
+            if (!_service.WowJokes.Any())
+            {
+                await Response().Error(strs.jokes_not_loaded).SendAsync();
+                return;
+            }
+
+            var joke = _service.WowJokes[new EllieRandom().Next(0, _service.WowJokes.Count)];
+            await Response().Confirm(joke.Question, joke.Answer).SendAsync();
+        }
+
+        [Cmd]
+        public async Task MagicItem()
+        {
+            if (!_service.MagicItems.Any())
+            {
+                await Response().Error(strs.magicitems_not_loaded).SendAsync();
+                return;
+            }
+
+            var item = _service.MagicItems[new EllieRandom().Next(0, _service.MagicItems.Count)];
+
+            await Response().Confirm("✨" + item.Name, item.Description).SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Osu/OsuCommands.cs b/src/EllieBot/Modules/Searches/Osu/OsuCommands.cs
new file mode 100644
index 0000000..f122c33
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Osu/OsuCommands.cs
@@ -0,0 +1,124 @@
+#nullable disable
+using EllieBot.Modules.Searches.Common;
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches
+{
+    [Group]
+    public partial class OsuCommands : EllieModule<OsuService>
+    {
+        private readonly IBotCredentials _creds;
+        private readonly IHttpClientFactory _httpFactory;
+
+        public OsuCommands(IBotCredentials creds, IHttpClientFactory factory)
+        {
+            _creds = creds;
+            _httpFactory = factory;
+        }
+
+        [Cmd]
+        public async Task Osu(string user, [Leftover] string mode = null)
+        {
+            if (string.IsNullOrWhiteSpace(user))
+                return;
+
+            try
+            {
+                if (string.IsNullOrWhiteSpace(_creds.OsuApiKey))
+                {
+                    await Response().Error(strs.osu_api_key).SendAsync();
+                    return;
+                }
+
+                var obj = await _service.GetOsuData(user, mode);
+                if (obj is null)
+                {
+                    await Response().Error(strs.osu_user_not_found).SendAsync();
+                    return;
+                }
+
+                var userId = obj.UserId;
+                var smode = OsuService.ResolveGameMode(obj.ModeNumber);
+
+
+                await Response()
+                      .Embed(_sender.CreateEmbed()
+                                    .WithOkColor()
+                                    .WithTitle($"osu! {smode} profile for {user}")
+                                    .WithThumbnailUrl($"https://a.ppy.sh/{userId}")
+                                    .WithDescription($"https://osu.ppy.sh/u/{userId}")
+                                    .AddField("Official Rank", $"#{obj.PpRank}", true)
+                                    .AddField("Country Rank",
+                                        $"#{obj.PpCountryRank} :flag_{obj.Country.ToLower()}:",
+                                        true)
+                                    .AddField("Total PP", Math.Round(obj.PpRaw, 2), true)
+                                    .AddField("Accuracy", Math.Round(obj.Accuracy, 2) + "%", true)
+                                    .AddField("Playcount", obj.Playcount, true)
+                                    .AddField("Level", Math.Round(obj.Level), true))
+                      .SendAsync();
+            }
+            catch (Exception ex)
+            {
+                await Response().Error(strs.osu_failed).SendAsync();
+                Log.Warning(ex, "Osu command failed");
+            }
+        }
+
+        [Cmd]
+        public async Task Gatari(string user, [Leftover] string mode = null)
+        {
+            var modeNumber = OsuService.ResolveGameMode(mode);
+            var modeStr = OsuService.ResolveGameMode(modeNumber);
+            var (userData, userStats) = await _service.GetGatariDataAsync(user, mode);
+            if (userStats is null)
+            {
+                await Response().Error(strs.osu_user_not_found).SendAsync();
+                return;
+            }
+
+            var embed = _sender.CreateEmbed()
+                               .WithOkColor()
+                               .WithTitle($"osu!Gatari {modeStr} profile for {user}")
+                               .WithThumbnailUrl($"https://a.gatari.pw/{userStats.Id}")
+                               .WithDescription($"https://osu.gatari.pw/u/{userStats.Id}")
+                               .AddField("Official Rank", $"#{userStats.Rank}", true)
+                               .AddField("Country Rank",
+                                   $"#{userStats.CountryRank} :flag_{userData.Country.ToLower()}:",
+                                   true)
+                               .AddField("Total PP", userStats.Pp, true)
+                               .AddField("Accuracy", $"{Math.Round(userStats.AvgAccuracy, 2)}%", true)
+                               .AddField("Playcount", userStats.Playcount, true)
+                               .AddField("Level", userStats.Level, true);
+
+            await Response().Embed(embed).SendAsync();
+        }
+
+        [Cmd]
+        public async Task Osu5(string user, [Leftover] string mode = null)
+        {
+            if (string.IsNullOrWhiteSpace(_creds.OsuApiKey))
+            {
+                await Response().Error("An osu! API key is required.").SendAsync();
+                return;
+            }
+
+            if (string.IsNullOrWhiteSpace(user))
+            {
+                await Response().Error("Please provide a username.").SendAsync();
+                return;
+            }
+            
+            var plays = await _service.GetOsuPlay(user, mode);
+            
+
+            var eb = _sender.CreateEmbed().WithOkColor().WithTitle($"Top 5 plays for {user}");
+
+            foreach(var (title, desc) in plays)
+                eb.AddField(title, desc);
+
+            await Response().Embed(eb).SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Osu/OsuService.cs b/src/EllieBot/Modules/Searches/Osu/OsuService.cs
new file mode 100644
index 0000000..18327c9
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Osu/OsuService.cs
@@ -0,0 +1,227 @@
+#nullable disable
+using EllieBot.Modules.Searches.Common;
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class OsuService : IEService
+{
+    private readonly IHttpClientFactory _httpFactory;
+    private readonly IBotCredentials _creds;
+
+    public OsuService(IHttpClientFactory httpFactory, IBotCredentials creds)
+    {
+        _httpFactory = httpFactory;
+        _creds = creds;
+    }
+
+    public async Task<OsuUserData> GetOsuData(string username, string mode)
+    {
+        using var http = _httpFactory.CreateClient();
+
+        var modeNumber = string.IsNullOrWhiteSpace(mode) ? 0 : ResolveGameMode(mode);
+        var userReq = $"https://osu.ppy.sh/api/get_user?k={_creds.OsuApiKey}&u={username}&m={modeNumber}";
+        var userResString = await http.GetStringAsync(userReq);
+
+        if (string.IsNullOrWhiteSpace(userResString))
+            return null;
+        var objs = JsonConvert.DeserializeObject<List<OsuUserData>>(userResString);
+
+        if (objs.Count == 0)
+        {
+            return null;
+        }
+
+        var obj = objs[0];
+
+        obj.ModeNumber = modeNumber;
+        return obj;
+    }
+
+    public static int ResolveGameMode(string mode)
+    {
+        switch (mode?.ToUpperInvariant())
+        {
+            case "STD":
+            case "STANDARD":
+                return 0;
+            case "TAIKO":
+                return 1;
+            case "CTB":
+            case "CATCHTHEBEAT":
+                return 2;
+            case "MANIA":
+            case "OSU!MANIA":
+                return 3;
+            default:
+                return 0;
+        }
+    }
+
+    public static string ResolveGameMode(int mode)
+    {
+        switch (mode)
+        {
+            case 0:
+                return "Standard";
+            case 1:
+                return "Taiko";
+            case 2:
+                return "Catch";
+            case 3:
+                return "Mania";
+            default:
+                return "Standard";
+        }
+    }
+
+    public async Task<(GatariUserData userData, GatariUserStats userStats)> GetGatariDataAsync(
+        string user,
+        string mode)
+    {
+        using var http = _httpFactory.CreateClient();
+        var modeNumber = string.IsNullOrWhiteSpace(mode) ? 0 : ResolveGameMode(mode);
+
+        var resString = await http.GetStringAsync($"https://api.gatari.pw/user/stats?u={user}&mode={modeNumber}");
+
+        var statsResponse = JsonConvert.DeserializeObject<GatariUserStatsResponse>(resString);
+        if (statsResponse.Code != 200 || statsResponse.Stats.Id == 0)
+        {
+            return default;
+        }
+
+        var usrResString = await http.GetStringAsync($"https://api.gatari.pw/users/get?u={user}");
+
+        var userData = JsonConvert.DeserializeObject<GatariUserResponse>(usrResString).Users[0];
+        var userStats = statsResponse.Stats;
+
+        return (userData, userStats);
+    }
+
+    public async Task<(string title, string desc)[]> GetOsuPlay(string user, string mode)
+    {
+        using var http = _httpFactory.CreateClient();
+        var m = 0;
+        if (!string.IsNullOrWhiteSpace(mode))
+            m = OsuService.ResolveGameMode(mode);
+
+        var reqString = "https://osu.ppy.sh/api/get_user_best"
+                        + $"?k={_creds.OsuApiKey}"
+                        + $"&u={Uri.EscapeDataString(user)}"
+                        + "&type=string"
+                        + "&limit=5"
+                        + $"&m={m}";
+
+        var resString = await http.GetStringAsync(reqString);
+        var obj = JsonConvert.DeserializeObject<List<OsuUserBests>>(resString);
+
+        var mapTasks = obj.Select(async item =>
+        {
+            var mapReqString = "https://osu.ppy.sh/api/get_beatmaps"
+                               + $"?k={_creds.OsuApiKey}"
+                               + $"&b={item.BeatmapId}";
+
+            var mapResString = await http.GetStringAsync(mapReqString);
+            var map = JsonConvert.DeserializeObject<List<OsuMapData>>(mapResString).FirstOrDefault();
+            if (map is null)
+                return default;
+            var pp = Math.Round(item.Pp, 2);
+            var acc = CalculateAcc(item, m);
+            var mods = ResolveMods(item.EnabledMods);
+
+            var title = $"{map.Artist}-{map.Title} ({map.Version})";
+            var desc = $@"[/b/{item.BeatmapId}](https://osu.ppy.sh/b/{item.BeatmapId})
+        {pp + "pp",-7} | {acc + "%",-7}
+        ";
+            if (mods != "+")
+                desc += Format.Bold(mods);
+
+            return (title, desc);
+        });
+
+        return await Task.WhenAll(mapTasks);
+    }
+
+    //https://osu.ppy.sh/wiki/Accuracy
+    private static double CalculateAcc(OsuUserBests play, int mode)
+    {
+        double hitPoints;
+        double totalHits;
+        if (mode == 0)
+        {
+            hitPoints = (play.Count50 * 50) + (play.Count100 * 100) + (play.Count300 * 300);
+            totalHits = play.Count50 + play.Count100 + play.Count300 + play.Countmiss;
+            totalHits *= 300;
+        }
+        else if (mode == 1)
+        {
+            hitPoints = (play.Countmiss * 0) + (play.Count100 * 0.5) + play.Count300;
+            totalHits = (play.Countmiss + play.Count100 + play.Count300) * 300;
+            hitPoints *= 300;
+        }
+        else if (mode == 2)
+        {
+            hitPoints = play.Count50 + play.Count100 + play.Count300;
+            totalHits = play.Countmiss + play.Count50 + play.Count100 + play.Count300 + play.Countkatu;
+        }
+        else
+        {
+            hitPoints = (play.Count50 * 50)
+                        + (play.Count100 * 100)
+                        + (play.Countkatu * 200)
+                        + ((play.Count300 + play.Countgeki) * 300);
+
+            totalHits = (play.Countmiss
+                         + play.Count50
+                         + play.Count100
+                         + play.Countkatu
+                         + play.Count300
+                         + play.Countgeki)
+                        * 300;
+        }
+
+
+        return Math.Round(hitPoints / totalHits * 100, 2);
+    }
+
+
+    //https://github.com/ppy/osu-api/wiki#mods
+    private static string ResolveMods(int mods)
+    {
+        var modString = "+";
+
+        if (IsBitSet(mods, 0))
+            modString += "NF";
+        if (IsBitSet(mods, 1))
+            modString += "EZ";
+        if (IsBitSet(mods, 8))
+            modString += "HT";
+
+        if (IsBitSet(mods, 3))
+            modString += "HD";
+        if (IsBitSet(mods, 4))
+            modString += "HR";
+        if (IsBitSet(mods, 6) && !IsBitSet(mods, 9))
+            modString += "DT";
+        if (IsBitSet(mods, 9))
+            modString += "NC";
+        if (IsBitSet(mods, 10))
+            modString += "FL";
+
+        if (IsBitSet(mods, 5))
+            modString += "SD";
+        if (IsBitSet(mods, 14))
+            modString += "PF";
+
+        if (IsBitSet(mods, 7))
+            modString += "RX";
+        if (IsBitSet(mods, 11))
+            modString += "AT";
+        if (IsBitSet(mods, 12))
+            modString += "SO";
+        return modString;
+    }
+
+    private static bool IsBitSet(int mods, int pos)
+        => (mods & (1 << pos)) != 0;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/PokemonSearchCommands.cs b/src/EllieBot/Modules/Searches/PokemonSearchCommands.cs
new file mode 100644
index 0000000..6250e87
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/PokemonSearchCommands.cs
@@ -0,0 +1,74 @@
+#nullable disable
+using EllieBot.Modules.Searches.Services;
+
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches
+{
+    [Group]
+    public partial class PokemonSearchCommands : EllieModule<SearchesService>
+    {
+        private readonly ILocalDataCache _cache;
+
+        public PokemonSearchCommands(ILocalDataCache cache)
+            => _cache = cache;
+
+        [Cmd]
+        public async Task Pokemon([Leftover] string pokemon = null)
+        {
+            pokemon = pokemon?.Trim().ToUpperInvariant();
+            if (string.IsNullOrWhiteSpace(pokemon))
+                return;
+
+            foreach (var kvp in await _cache.GetPokemonsAsync())
+            {
+                if (kvp.Key.ToUpperInvariant() == pokemon.ToUpperInvariant())
+                {
+                    var p = kvp.Value;
+                    await Response().Embed(_sender.CreateEmbed()
+                                                    .WithOkColor()
+                                                    .WithTitle(kvp.Key.ToTitleCase())
+                                                    .WithDescription(p.BaseStats.ToString())
+                                                    .WithThumbnailUrl(
+                                                        $"https://assets.pokemon.com/assets/cms2/img/pokedex/detail/{p.Id.ToString("000")}.png")
+                                                    .AddField(GetText(strs.types), string.Join("\n", p.Types), true)
+                                                    .AddField(GetText(strs.height_weight),
+                                                        GetText(strs.height_weight_val(p.HeightM, p.WeightKg)),
+                                                        true)
+                                                    .AddField(GetText(strs.abilities),
+                                                        string.Join("\n", p.Abilities.Select(a => a.Value)),
+                                                        true)).SendAsync();
+                    return;
+                }
+            }
+
+            await Response().Error(strs.pokemon_none).SendAsync();
+        }
+
+        [Cmd]
+        public async Task PokemonAbility([Leftover] string ability = null)
+        {
+            ability = ability?.Trim().ToUpperInvariant().Replace(" ", "", StringComparison.InvariantCulture);
+            if (string.IsNullOrWhiteSpace(ability))
+                return;
+            foreach (var kvp in await _cache.GetPokemonAbilitiesAsync())
+            {
+                if (kvp.Key.ToUpperInvariant() == ability)
+                {
+                    await Response().Embed(_sender.CreateEmbed()
+                                                    .WithOkColor()
+                                                    .WithTitle(kvp.Value.Name)
+                                                    .WithDescription(string.IsNullOrWhiteSpace(kvp.Value.Desc)
+                                                        ? kvp.Value.ShortDesc
+                                                        : kvp.Value.Desc)
+                                                    .AddField(GetText(strs.rating),
+                                                        kvp.Value.Rating.ToString(Culture),
+                                                        true)).SendAsync();
+                    return;
+                }
+            }
+
+            await Response().Error(strs.pokemon_ability_none).SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Religious/Common/BibleVerse.cs b/src/EllieBot/Modules/Searches/Religious/Common/BibleVerse.cs
new file mode 100644
index 0000000..77b74f5
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Religious/Common/BibleVerse.cs
@@ -0,0 +1,13 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public class BibleVerse
+{
+    [JsonPropertyName("book_name")]
+    public required string BookName { get; set; }
+
+    public required int Chapter { get; set; }
+    public required int Verse { get; set; }
+    public required string Text { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Religious/Common/BibleVerses.cs b/src/EllieBot/Modules/Searches/Religious/Common/BibleVerses.cs
new file mode 100644
index 0000000..4e7b66d
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Religious/Common/BibleVerses.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.Searches;
+
+public class BibleVerses
+{
+    public string? Error { get; set; }
+    public BibleVerse[]? Verses { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Religious/Common/QuranAyah.cs b/src/EllieBot/Modules/Searches/Religious/Common/QuranAyah.cs
new file mode 100644
index 0000000..6880737
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Religious/Common/QuranAyah.cs
@@ -0,0 +1,19 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class QuranAyah
+{
+    [JsonPropertyName("number")]
+    public int Number { get; set; }
+
+    [JsonPropertyName("audio")]
+    public string Audio { get; set; }
+
+    [JsonPropertyName("name")]
+    public string Name { get; set; }
+
+    [JsonPropertyName("text")]
+    public string Text { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Religious/Common/QuranResponse.cs b/src/EllieBot/Modules/Searches/Religious/Common/QuranResponse.cs
new file mode 100644
index 0000000..86bee95
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Religious/Common/QuranResponse.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class QuranResponse<T>
+{
+    [JsonPropertyName("code")]
+    public required int Code { get; set; }
+
+    [JsonPropertyName("status")]
+    public required string Status { get; set; }
+
+    [JsonPropertyName("data")]
+    public required T[] Data { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Religious/ReligiousApiService.cs b/src/EllieBot/Modules/Searches/Religious/ReligiousApiService.cs
new file mode 100644
index 0000000..ccef2b8
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Religious/ReligiousApiService.cs
@@ -0,0 +1,62 @@
+using OneOf;
+using OneOf.Types;
+using System.Net;
+using System.Net.Http.Json;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class ReligiousApiService : IEService
+{
+    private readonly IHttpClientFactory _httpFactory;
+
+    public ReligiousApiService(IHttpClientFactory httpFactory)
+    {
+        _httpFactory = httpFactory;
+    }
+
+    public async Task<OneOf<BibleVerse, Error<string>>> GetBibleVerseAsync(string book, string chapterAndVerse)
+    {
+        if (string.IsNullOrWhiteSpace(book) || string.IsNullOrWhiteSpace(chapterAndVerse))
+            return new Error<string>("Invalid input.");
+
+
+        book = Uri.EscapeDataString(book);
+        chapterAndVerse = Uri.EscapeDataString(chapterAndVerse);
+
+        using var http = _httpFactory.CreateClient();
+        try
+        {
+            var res = await http.GetFromJsonAsync<BibleVerses>($"https://bible-api.com/{book} {chapterAndVerse}");
+
+            if (res is null || res.Error is not null || res.Verses is null || res.Verses.Length == 0)
+            {
+                return new Error<string>(res?.Error ?? "No verse found.");
+            }
+
+            return res.Verses[0];
+        }
+        catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
+        {
+            return new Error<string>("No verse found.");
+        }
+    }
+
+    public async Task<OneOf<QuranResponse<QuranAyah>, Error<LocStr>>> GetQuranVerseAsync(string ayah)
+    {
+        if (string.IsNullOrWhiteSpace(ayah))
+            return new Error<LocStr>(strs.invalid_input);
+
+        ayah = Uri.EscapeDataString(ayah);
+
+        using var http = _httpFactory.CreateClient();
+        var res = await http.GetFromJsonAsync<QuranResponse<QuranAyah>>(
+            $"https://api.alquran.cloud/v1/ayah/{ayah}/editions/en.asad,ar.alafasy");
+
+        if (res is null or not { Code: 200 })
+        {
+            return new Error<LocStr>(strs.not_found);
+        }
+
+        return res;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Religious/ReligiousCommands.cs b/src/EllieBot/Modules/Searches/Religious/ReligiousCommands.cs
new file mode 100644
index 0000000..074cff5
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Religious/ReligiousCommands.cs
@@ -0,0 +1,60 @@
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches
+{
+    public partial class ReligiousCommands : EllieModule<ReligiousApiService>
+    {
+        private readonly IHttpClientFactory _httpFactory;
+
+        public ReligiousCommands(IHttpClientFactory httpFactory)
+            => _httpFactory = httpFactory;
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Bible(string book, string chapterAndVerse)
+        {
+            var res = await _service.GetBibleVerseAsync(book, chapterAndVerse);
+
+            if (!res.TryPickT0(out var verse, out var error))
+            {
+                await Response().Error(error.Value).SendAsync();
+                return;
+            }
+
+            await Response()
+                  .Embed(_sender.CreateEmbed()
+                                .WithOkColor()
+                                .WithTitle($"{verse.BookName} {verse.Chapter}:{verse.Verse}")
+                                .WithDescription(verse.Text))
+                  .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Quran(string ayah)
+        {
+            var res = await _service.GetQuranVerseAsync(ayah);
+
+            if (!res.TryPickT0(out var qr, out var error))
+            {
+                await Response().Error(error.Value).SendAsync();
+                return;
+            }
+
+            var english = qr.Data[0];
+            var arabic = qr.Data[1];
+
+            using var http = _httpFactory.CreateClient();
+            await using var audio = await http.GetStreamAsync(arabic.Audio);
+
+            await Response()
+                  .Embed(_sender.CreateEmbed()
+                                .WithOkColor()
+                                .AddField("Arabic", arabic.Text)
+                                .AddField("English", english.Text)
+                                .WithFooter(arabic.Number.ToString()))
+                  .File(audio, Uri.EscapeDataString(ayah) + ".mp3")
+                  .SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs b/src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs
new file mode 100644
index 0000000..bfc22d4
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/DefaultSearchServiceFactory.cs
@@ -0,0 +1,65 @@
+using EllieBot.Modules.Searches.GoogleScrape;
+using EllieBot.Modules.Searches.Youtube;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class DefaultSearchServiceFactory : ISearchServiceFactory, INService
+{
+    private readonly SearchesConfigService _scs;
+    private readonly SearxSearchService _sss;
+    private readonly GoogleSearchService _gss;
+
+    private readonly YtdlpYoutubeSearchService _ytdlp;
+    private readonly YtdlYoutubeSearchService _ytdl;
+    private readonly YoutubeDataApiSearchService _ytdata;
+    private readonly InvidiousYtSearchService _iYtSs;
+    private readonly GoogleScrapeService _gscs;
+
+    public DefaultSearchServiceFactory(
+        SearchesConfigService scs,
+        GoogleSearchService gss,
+        GoogleScrapeService gscs,
+        SearxSearchService sss,
+        YtdlpYoutubeSearchService ytdlp,
+        YtdlYoutubeSearchService ytdl,
+        YoutubeDataApiSearchService ytdata,
+        InvidiousYtSearchService iYtSs)
+    {
+        _scs = scs;
+        _sss = sss;
+        _gss = gss;
+        _gscs = gscs;
+        _iYtSs = iYtSs;
+
+        _ytdlp = ytdlp;
+        _ytdl = ytdl;
+        _ytdata = ytdata;
+    }
+
+    public ISearchService GetSearchService(string? hint = null)
+        => _scs.Data.WebSearchEngine switch
+        {
+            WebSearchEngine.Google => _gss,
+            WebSearchEngine.Google_Scrape => _gscs,
+            WebSearchEngine.Searx => _sss,
+            _ => _gss
+        };
+
+    public ISearchService GetImageSearchService(string? hint = null)
+        => _scs.Data.ImgSearchEngine switch
+        {
+            ImgSearchEngine.Google => _gss,
+            ImgSearchEngine.Searx => _sss,
+            _ => _gss
+        };
+
+    public IYoutubeSearchService GetYoutubeSearchService(string? hint = null)
+        => _scs.Data.YtProvider switch
+        {
+            YoutubeSearcher.YtDataApiv3 => _ytdata,
+            YoutubeSearcher.Ytdlp => _ytdlp,
+            YoutubeSearcher.Ytdl => _ytdl,
+            YoutubeSearcher.Invidious => _iYtSs,
+            _ => _ytdl
+        };
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs
new file mode 100644
index 0000000..74fd3c0
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleCustomSearchResult.cs
@@ -0,0 +1,22 @@
+using EllieBot.Modules.Searches;
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Services;
+
+public sealed class GoogleCustomSearchResult : ISearchResult
+{
+    ISearchResultInformation ISearchResult.Info
+        => Info;
+
+    public string? Answer
+        => null;
+
+    IReadOnlyCollection<ISearchResultEntry> ISearchResult.Entries
+        => Entries ?? Array.Empty<OfficialGoogleSearchResultEntry>();
+
+    [JsonPropertyName("searchInformation")]
+    public GoogleSearchResultInformation Info { get; init; } = null!;
+
+    [JsonPropertyName("items")]
+    public IReadOnlyCollection<OfficialGoogleSearchResultEntry>? Entries { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs
new file mode 100644
index 0000000..503a1cc
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageData.cs
@@ -0,0 +1,12 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Services;
+
+public sealed class GoogleImageData
+{
+    [JsonPropertyName("contextLink")]
+    public string ContextLink { get; init; } = null!;
+
+    [JsonPropertyName("thumbnailLink")]
+    public string ThumbnailLink { get; init; } = null!;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs
new file mode 100644
index 0000000..9cf406b
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResult.cs
@@ -0,0 +1,19 @@
+using EllieBot.Modules.Searches;
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Services;
+
+public sealed class GoogleImageResult : IImageSearchResult
+{
+    ISearchResultInformation IImageSearchResult.Info
+        => Info;
+
+    IReadOnlyCollection<IImageSearchResultEntry> IImageSearchResult.Entries
+        => Entries ?? Array.Empty<GoogleImageResultEntry>();
+
+    [JsonPropertyName("searchInformation")]
+    public GoogleSearchResultInformation Info { get; init; } = null!;
+
+    [JsonPropertyName("items")]
+    public IReadOnlyCollection<GoogleImageResultEntry>? Entries { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs
new file mode 100644
index 0000000..cd06fae
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleImageResultEntry.cs
@@ -0,0 +1,13 @@
+using EllieBot.Modules.Searches;
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Services;
+
+public sealed class GoogleImageResultEntry : IImageSearchResultEntry
+{
+    [JsonPropertyName("link")]
+    public string Link { get; init; } = null!;
+
+    [JsonPropertyName("image")]
+    public GoogleImageData Image { get; init; } = null!;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs
new file mode 100644
index 0000000..0106c0a
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchResultInformation.cs
@@ -0,0 +1,13 @@
+using EllieBot.Modules.Searches;
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Services;
+
+public sealed class GoogleSearchResultInformation : ISearchResultInformation
+{
+    [JsonPropertyName("formattedTotalResults")]
+    public string TotalResults { get; init; } = null!;
+
+    [JsonPropertyName("formattedSearchTime")]
+    public string SearchTime { get; init; } = null!;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs
new file mode 100644
index 0000000..563012d
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Google/GoogleSearchService.cs
@@ -0,0 +1,66 @@
+using MorseCode.ITask;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class GoogleSearchService : SearchServiceBase, INService
+{
+    private readonly IBotCredsProvider _creds;
+    private readonly IHttpClientFactory _httpFactory;
+
+    public GoogleSearchService(IBotCredsProvider creds, IHttpClientFactory httpFactory)
+    {
+        _creds = creds;
+        _httpFactory = httpFactory;
+    }
+    
+    public override async ITask<GoogleImageResult?> SearchImagesAsync(string query)
+    {
+        ArgumentNullException.ThrowIfNull(query);
+
+        var creds = _creds.GetCreds();
+        var key = creds.Google.ImageSearchId;
+        var cx = string.IsNullOrWhiteSpace(key)
+            ? "c3f56de3be2034c07"
+            : key;
+        
+        using var http = _httpFactory.CreateClient("google:search");
+        http.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");
+        await using var stream = await http.GetStreamAsync(
+            $"https://customsearch.googleapis.com/customsearch/v1"
+            + $"?cx={cx}"
+            + $"&q={Uri.EscapeDataString(query)}"
+            + $"&fields=items(image(contextLink%2CthumbnailLink)%2Clink)%2CsearchInformation"
+            + $"&key={creds.GoogleApiKey}"
+            + $"&searchType=image"
+            + $"&safe=active");
+        
+        var result = await System.Text.Json.JsonSerializer.DeserializeAsync<GoogleImageResult>(stream);
+
+        return result;
+    }
+
+    public override async ITask<GoogleCustomSearchResult?> SearchAsync(string? query)
+    {
+        ArgumentNullException.ThrowIfNull(query);
+
+        var creds = _creds.GetCreds();
+        var key = creds.Google.SearchId;
+        var cx = string.IsNullOrWhiteSpace(key)
+            ? "c7f1dac95987d4571"
+            : key;
+        
+        using var http = _httpFactory.CreateClient("google:search");
+        http.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");
+        await using var stream = await http.GetStreamAsync(
+            $"https://customsearch.googleapis.com/customsearch/v1"
+            + $"?cx={cx}"
+            + $"&q={Uri.EscapeDataString(query)}"
+            + $"&fields=items(title%2Clink%2CdisplayLink%2Csnippet)%2CsearchInformation"
+            + $"&key={creds.GoogleApiKey}"
+            + $"&safe=active");
+        
+        var result = await System.Text.Json.JsonSerializer.DeserializeAsync<GoogleCustomSearchResult>(stream);
+
+        return result;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs
new file mode 100644
index 0000000..bf23180
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Google/OfficialGoogleSearchResultEntry.cs
@@ -0,0 +1,19 @@
+using EllieBot.Modules.Searches;
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Services;
+
+public sealed class OfficialGoogleSearchResultEntry : ISearchResultEntry
+{
+    [JsonPropertyName("title")]
+    public string Title { get; init; } = null!;
+
+    [JsonPropertyName("link")]
+    public string Url { get; init; } = null!;
+
+    [JsonPropertyName("displayLink")]
+    public string DisplayUrl { get; init; } = null!;
+
+    [JsonPropertyName("snippet")]
+    public string Description { get; init; } = null!;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs
new file mode 100644
index 0000000..2cb9e0f
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/GoogleScrapeService.cs
@@ -0,0 +1,121 @@
+using AngleSharp.Html.Dom;
+using AngleSharp.Html.Parser;
+using MorseCode.ITask;
+
+namespace EllieBot.Modules.Searches.GoogleScrape;
+
+public sealed class GoogleScrapeService : SearchServiceBase, INService
+{
+    private static readonly HtmlParser _googleParser = new(new()
+    {
+        IsScripting = false,
+        IsEmbedded = false,
+        IsSupportingProcessingInstructions = false,
+        IsKeepingSourceReferences = false,
+        IsNotSupportingFrames = true
+    });
+
+    
+    private readonly IHttpClientFactory _httpFactory;
+
+    public GoogleScrapeService(IHttpClientFactory httpClientFactory)
+        => _httpFactory = httpClientFactory;
+
+    public override async ITask<ISearchResult?> SearchAsync(string? query)
+    {
+        ArgumentNullException.ThrowIfNull(query);
+        
+        query = Uri.EscapeDataString(query)?.Replace(' ', '+');
+
+        var fullQueryLink = $"https://www.google.ca/search?q={query}&safe=on&lr=lang_eng&hl=en&ie=utf-8&oe=utf-8";
+
+        using var msg = new HttpRequestMessage(HttpMethod.Get, fullQueryLink);
+        msg.Headers.Add("User-Agent",
+            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.77 Safari/537.36");
+        msg.Headers.Add("Cookie", "CONSENT=YES+shp.gws-20210601-0-RC2.en+FX+423;");
+
+        using var http = _httpFactory.CreateClient();
+        http.DefaultRequestHeaders.Clear();
+
+        using var response = await http.SendAsync(msg);
+        await using var content = await response.Content.ReadAsStreamAsync();
+
+        using var document = await _googleParser.ParseDocumentAsync(content);
+        var elems = document.QuerySelectorAll("div.g, div.mnr-c > div > div");
+
+        var resultsElem = document.QuerySelector("#result-stats");
+        var resultsArr = resultsElem?.TextContent.Split("results");
+        var totalResults = resultsArr?.Length is null or 0
+            ? null
+            : resultsArr[0];
+
+        var time = resultsArr is null or {Length: < 2}
+            ? null
+            : resultsArr[1]
+              .Replace("(", string.Empty)
+              .Replace("seconds)", string.Empty);
+        
+        //var time = resultsElem.Children.FirstOrDefault()?.TextContent
+        //^ this doesn't work for some reason, <nobr> is completely missing in parsed collection
+        if (!elems.Any())
+            return default;
+
+        var results = elems.Select(elem =>
+                           {
+                               var aTag = elem.QuerySelector("a");
+
+                               if (aTag is null)
+                                   return null;
+
+                               var url = ((IHtmlAnchorElement)aTag).Href;
+                               var title = aTag.QuerySelector("h3")?.TextContent;
+
+                               var txt = aTag.ParentElement
+                                             ?.NextElementSibling
+                                             ?.QuerySelector("span")
+                                             ?.TextContent
+                                             .StripHtml()
+                                         ?? elem
+                                            ?.QuerySelectorAll("span")
+                                            .Skip(3)
+                                            .FirstOrDefault()
+                                            ?.TextContent
+                                            .StripHtml();
+                                             // .Select(x => x.TextContent.StripHtml())
+                                             // .Join("\n");
+
+                               if (string.IsNullOrWhiteSpace(url)
+                                   || string.IsNullOrWhiteSpace(title)
+                                   || string.IsNullOrWhiteSpace(txt))
+                                   return null;
+
+                               return new PlainSearchResultEntry
+                               {
+                                   Title = title,
+                                   Url = url,
+                                   DisplayUrl = url,
+                                   Description = txt
+                               };
+                           })
+                           .Where(x => x is not null)
+                           .ToList();
+
+        // return new GoogleSearchResult(results.AsReadOnly(), fullQueryLink, totalResults);
+
+        return new PlainGoogleScrapeSearchResult()
+        {
+            Answer = null,
+            Entries = results!,
+            Info = new PlainSearchResultInfo()
+            {
+                SearchTime = time ?? "?",
+                TotalResults = totalResults ?? "?"
+            }
+        };
+    }
+
+    
+    // someone can mr this
+    public override ITask<IImageSearchResult?> SearchImagesAsync(string query)
+        => throw new NotSupportedException();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs
new file mode 100644
index 0000000..9abc999
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainGoogleScrapeSearchResult.cs
@@ -0,0 +1,8 @@
+namespace EllieBot.Modules.Searches.GoogleScrape;
+
+public class PlainGoogleScrapeSearchResult : ISearchResult
+{
+    public required string? Answer { get; init;  } 
+    public required IReadOnlyCollection<ISearchResultEntry> Entries { get; init; }
+    public required ISearchResultInformation Info { get; init; } 
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs
new file mode 100644
index 0000000..99fad02
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultEntry.cs
@@ -0,0 +1,9 @@
+namespace EllieBot.Modules.Searches.GoogleScrape;
+
+public sealed class PlainSearchResultEntry : ISearchResultEntry
+{
+    public string Title { get; init; } = null!;
+    public string Url { get; init; } = null!;
+    public string DisplayUrl { get; init; } = null!;
+    public string? Description { get; init; } = null!;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs
new file mode 100644
index 0000000..92ba006
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/GoogleScrape/PlainSearchResultInfo.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.Searches.GoogleScrape;
+
+public sealed class PlainSearchResultInfo : ISearchResultInformation
+{
+    public string TotalResults { get; init; } = null!;
+    public string SearchTime { get; init; } = null!;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs b/src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs
new file mode 100644
index 0000000..d470613
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/IImageSearchResult.cs
@@ -0,0 +1,13 @@
+namespace EllieBot.Modules.Searches;
+
+public interface IImageSearchResult
+{
+    ISearchResultInformation Info { get; }
+    
+    IReadOnlyCollection<IImageSearchResultEntry> Entries { get; }
+}
+
+public interface IImageSearchResultEntry
+{
+    string Link { get; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/ISearchResult.cs b/src/EllieBot/Modules/Searches/Search/ISearchResult.cs
new file mode 100644
index 0000000..d910819
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/ISearchResult.cs
@@ -0,0 +1,8 @@
+namespace EllieBot.Modules.Searches;
+
+public interface ISearchResult
+{
+    string? Answer { get; }
+    IReadOnlyCollection<ISearchResultEntry> Entries { get; }
+    ISearchResultInformation Info { get; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs
new file mode 100644
index 0000000..e4dfc44
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/ISearchResultEntry.cs
@@ -0,0 +1,9 @@
+namespace EllieBot.Modules.Searches;
+
+public interface ISearchResultEntry
+{
+    string Title { get; }
+    string Url { get; }
+    string DisplayUrl { get; }
+    string? Description { get; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs b/src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs
new file mode 100644
index 0000000..dfd9a53
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/ISearchResultInformation.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.Searches;
+
+public interface ISearchResultInformation
+{
+    string TotalResults { get; }
+    string SearchTime { get; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/ISearchService.cs b/src/EllieBot/Modules/Searches/Search/ISearchService.cs
new file mode 100644
index 0000000..7454a60
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/ISearchService.cs
@@ -0,0 +1,9 @@
+using MorseCode.ITask;
+
+namespace EllieBot.Modules.Searches;
+
+public interface ISearchService
+{
+    ITask<ISearchResult?> SearchAsync(string? query);
+    ITask<IImageSearchResult?> SearchImagesAsync(string query);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs b/src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs
new file mode 100644
index 0000000..bb46b09
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/ISearchServiceFactory.cs
@@ -0,0 +1,10 @@
+using EllieBot.Modules.Searches.Youtube;
+
+namespace EllieBot.Modules.Searches;
+
+public interface ISearchServiceFactory
+{
+    public ISearchService GetSearchService(string? hint = null);
+    public ISearchService GetImageSearchService(string? hint = null);
+    public IYoutubeSearchService GetYoutubeSearchService(string? hint = null);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/SearchCommands.cs b/src/EllieBot/Modules/Searches/Search/SearchCommands.cs
new file mode 100644
index 0000000..63e0821
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/SearchCommands.cs
@@ -0,0 +1,205 @@
+using EllieBot.Modules.Searches.Youtube;
+
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches
+{
+    public partial class SearchCommands : EllieModule
+    {
+        private readonly ISearchServiceFactory _searchFactory;
+        private readonly IBotCache _cache;
+
+        public SearchCommands(
+            ISearchServiceFactory searchFactory,
+            IBotCache cache)
+        {
+            _searchFactory = searchFactory;
+            _cache = cache;
+        }
+
+        [Cmd]
+        public async Task Google([Leftover] string? query = null)
+        {
+            query = query?.Trim();
+
+            if (string.IsNullOrWhiteSpace(query))
+            {
+                await Response().Error(strs.specify_search_params).SendAsync();
+                return;
+            }
+
+            _ = ctx.Channel.TriggerTypingAsync();
+
+            var search = _searchFactory.GetSearchService();
+            var data = await search.SearchAsync(query);
+
+            if (data is null or { Entries: null or { Count: 0 } })
+            {
+                await Response().Error(strs.no_results).SendAsync();
+                return;
+            }
+
+            // 3 with an answer
+            // 4 without an answer
+            // 5 is ideal but it lookes horrible on mobile
+
+            var takeCount = string.IsNullOrWhiteSpace(data.Answer)
+                ? 4
+                : 3;
+
+            var descStr = data.Entries
+                              .Take(takeCount)
+                              .Select(static res => $@"**[{Format.Sanitize(res.Title)}]({res.Url})**
+*{Format.EscapeUrl(res.DisplayUrl)}*
+{Format.Sanitize(res.Description ?? "-")}")
+                              .Join("\n\n");
+
+            if (!string.IsNullOrWhiteSpace(data.Answer))
+                descStr = Format.Code(data.Answer) + "\n\n" + descStr;
+
+            descStr = descStr.TrimTo(4096);
+
+            var embed = _sender.CreateEmbed()
+                               .WithOkColor()
+                               .WithAuthor(ctx.User)
+                               .WithTitle(query.TrimTo(64)!)
+                               .WithDescription(descStr)
+                               .WithFooter(
+                                   GetText(strs.results_in(data.Info.TotalResults, data.Info.SearchTime)),
+                                   "https://i.imgur.com/G46fm8J.png");
+
+            await Response().Embed(embed).SendAsync();
+        }
+
+        [Cmd]
+        public async Task Image([Leftover] string query)
+        {
+            query = query.Trim();
+
+            if (string.IsNullOrWhiteSpace(query))
+            {
+                await Response().Error(strs.specify_search_params).SendAsync();
+                return;
+            }
+
+            _ = ctx.Channel.TriggerTypingAsync();
+
+            var search = _searchFactory.GetImageSearchService();
+            var data = await search.SearchImagesAsync(query);
+
+            if (data is null or { Entries: null or { Count: 0 } })
+            {
+                await Response().Error(strs.no_search_results).SendAsync();
+                return;
+            }
+
+            var embeds = new List<EmbedBuilder>(4);
+
+
+            EmbedBuilder CreateEmbed(IImageSearchResultEntry entry)
+            {
+                return _sender.CreateEmbed()
+                              .WithOkColor()
+                              .WithAuthor(ctx.User)
+                              .WithTitle(query)
+                              .WithUrl("https://google.com")
+                              .WithImageUrl(entry.Link);
+            }
+
+            await Response()
+                  .Paginated()
+                  .Items(data.Entries)
+                  .PageSize(1)
+                  .AddFooter(false)
+                  .Page((items, _) =>
+                  {
+                      var item = items.FirstOrDefault();
+
+                      if (item is null)
+                          return _sender.CreateEmbed()
+                                        .WithDescription(GetText(strs.no_search_results));
+
+                      var embed = CreateEmbed(item);
+                      embeds.Add(embed);
+
+                      return embed;
+                  })
+                  .SendAsync();
+        }
+
+        private TypedKey<string> GetYtCacheKey(string query)
+            => new($"search:youtube:{query}");
+
+        private async Task AddYoutubeUrlToCacheAsync(string query, string url)
+            => await _cache.AddAsync(GetYtCacheKey(query), url, expiry: 1.Hours());
+
+        private async Task<VideoInfo?> GetYoutubeUrlFromCacheAsync(string query)
+        {
+            var result = await _cache.GetAsync(GetYtCacheKey(query));
+
+            if (!result.TryGetValue(out var url) || string.IsNullOrWhiteSpace(url))
+                return null;
+
+            return new VideoInfo()
+            {
+                Url = url
+            };
+        }
+
+        [Cmd]
+        public async Task Youtube([Leftover] string? query = null)
+        {
+            query = query?.Trim();
+
+            if (string.IsNullOrWhiteSpace(query))
+            {
+                await Response().Error(strs.specify_search_params).SendAsync();
+                return;
+            }
+
+            _ = ctx.Channel.TriggerTypingAsync();
+
+            var maybeResult = await GetYoutubeUrlFromCacheAsync(query)
+                              ?? await _searchFactory.GetYoutubeSearchService().SearchAsync(query);
+            if (maybeResult is not { } result || result is { Url: null })
+            {
+                await Response().Error(strs.no_results).SendAsync();
+                return;
+            }
+
+            await AddYoutubeUrlToCacheAsync(query, result.Url);
+            await Response().Text(result.Url).SendAsync();
+        }
+
+//     [Cmd]
+//     public async Task DuckDuckGo([Leftover] string query = null)
+//     {
+//         query = query?.Trim();
+//         if (!await ValidateQuery(query))
+//             return;
+//
+//         _ = ctx.Channel.TriggerTypingAsync();
+//
+//         var data = await _service.DuckDuckGoSearchAsync(query);
+//         if (data is null)
+//         {
+//             await Response().Error(strs.no_results).SendAsync();
+//             return;
+//         }
+//
+//         var desc = data.Results.Take(5)
+//                        .Select(res => $@"[**{res.Title}**]({res.Link})
+// {res.Text.TrimTo(380 - res.Title.Length - res.Link.Length)}");
+//
+//         var descStr = string.Join("\n\n", desc);
+//
+//         var embed = _sender.CreateEmbed()
+//                        .WithAuthor(ctx.User.ToString(),
+//                            "https://upload.wikimedia.org/wikipedia/en/9/90/The_DuckDuckGo_Duck.png")
+//                        .WithDescription($"{GetText(strs.search_for)} **{query}**\n\n" + descStr)
+//                        .WithOkColor();
+//
+//         await Response().Embed(embed).SendAsync();
+//     }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs b/src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs
new file mode 100644
index 0000000..c346306
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/SearchServiceBase.cs
@@ -0,0 +1,9 @@
+using MorseCode.ITask;
+
+namespace EllieBot.Modules.Searches;
+
+public abstract class SearchServiceBase : ISearchService
+{
+    public abstract ITask<ISearchResult?> SearchAsync(string? query);
+    public abstract ITask<IImageSearchResult?> SearchImagesAsync(string query);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs
new file mode 100644
index 0000000..54fdcdd
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResult.cs
@@ -0,0 +1,28 @@
+using System.Globalization;
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class SearxImageSearchResult : IImageSearchResult
+{
+    public string SearchTime { get; set; } = null!;
+
+    public ISearchResultInformation Info
+        => new SearxSearchResultInformation()
+        {
+            SearchTime = SearchTime,
+            TotalResults = NumberOfResults.ToString("N", CultureInfo.InvariantCulture)
+        };
+
+    public IReadOnlyCollection<IImageSearchResultEntry> Entries
+        => Results;
+
+    [JsonPropertyName("results")]
+    public List<SearxImageSearchResultEntry> Results { get; set; } = new List<SearxImageSearchResultEntry>();
+
+    [JsonPropertyName("query")]
+    public string Query { get; set; } = null!;
+
+    [JsonPropertyName("number_of_results")]
+    public double NumberOfResults { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs
new file mode 100644
index 0000000..888a2ce
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxImageSearchResultEntry.cs
@@ -0,0 +1,14 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class SearxImageSearchResultEntry : IImageSearchResultEntry
+{
+    public string Link
+        => ImageSource.StartsWith("//")
+            ? "https:" + ImageSource
+            : ImageSource;
+
+    [JsonPropertyName("img_src")]
+    public string ImageSource { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs
new file mode 100644
index 0000000..1fd9ee2
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxInfobox.cs
@@ -0,0 +1,30 @@
+// using System.Text.Json.Serialization;
+//
+// namespace EllieBot.Modules.Searches;
+//
+// public sealed class SearxInfobox
+// {
+//     [JsonPropertyName("infobox")]
+//     public string Infobox { get; set; }
+//
+//     [JsonPropertyName("id")]
+//     public string Id { get; set; }
+//
+//     [JsonPropertyName("content")]
+//     public string Content { get; set; }
+//
+//     [JsonPropertyName("img_src")]
+//     public string ImgSrc { get; set; }
+//
+//     [JsonPropertyName("urls")]
+//     public List<SearxUrlData> Urls { get; } = new List<SearxUrlData>();
+//
+//     [JsonPropertyName("engine")]
+//     public string Engine { get; set; }
+//
+//     [JsonPropertyName("engines")]
+//     public List<string> Engines { get; } = new List<string>();
+//
+//     [JsonPropertyName("attributes")]
+//     public List<SearxSearchAttribute> Attributes { get; } = new List<SearxSearchAttribute>();
+// }
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs
new file mode 100644
index 0000000..7071ea7
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchAttribute.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class SearxSearchAttribute
+{
+    [JsonPropertyName("label")]
+    public string? Label { get; set; }
+
+    [JsonPropertyName("value")]
+    public string? Value { get; set; }
+
+    [JsonPropertyName("entity")]
+    public string? Entity { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs
new file mode 100644
index 0000000..3483548
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResult.cs
@@ -0,0 +1,47 @@
+using System.Globalization;
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class SearxSearchResult : ISearchResult
+{
+    [JsonPropertyName("query")]
+    public string Query { get; set; } = null!;
+
+    [JsonPropertyName("number_of_results")]
+    public double NumberOfResults { get; set; }
+
+    [JsonPropertyName("results")]
+    public List<SearxSearchResultEntry> Results { get; set; } = new List<SearxSearchResultEntry>();
+
+    [JsonPropertyName("answers")]
+    public List<string> Answers { get; set; } = new List<string>();
+    //
+    // [JsonPropertyName("corrections")]
+    // public List<object> Corrections { get; } = new List<object>();
+
+    // [JsonPropertyName("infoboxes")]
+    // public List<InfoboxModel> Infoboxes { get; } = new List<InfoboxModel>();
+    //
+    // [JsonPropertyName("suggestions")]
+    // public List<string> Suggestions { get; } = new List<string>();
+
+    // [JsonPropertyName("unresponsive_engines")]
+    // public List<object> UnresponsiveEngines { get; } = new List<object>();
+
+
+    public string SearchTime { get; set; } = null!;
+
+    public IReadOnlyCollection<ISearchResultEntry> Entries
+        => Results;
+
+    public ISearchResultInformation Info
+        => new SearxSearchResultInformation()
+        {
+            SearchTime = SearchTime,
+            TotalResults = NumberOfResults.ToString("N", CultureInfo.InvariantCulture)
+        };
+
+    public string? Answer
+        => Answers.FirstOrDefault();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs
new file mode 100644
index 0000000..9670a17
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultEntry.cs
@@ -0,0 +1,51 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class SearxSearchResultEntry : ISearchResultEntry
+{
+    public string DisplayUrl
+        => Url;
+
+    public string Description
+        => Content.TrimTo(768)!;
+
+    [JsonPropertyName("url")]
+    public string Url { get; set; } = null!;
+
+    [JsonPropertyName("title")]
+    public string Title { get; set; } = null!;
+
+    [JsonPropertyName("content")]
+    public string? Content { get; set; }
+
+    // [JsonPropertyName("engine")]
+    // public string Engine { get; set; }
+    //
+    // [JsonPropertyName("parsed_url")]
+    // public List<string> ParsedUrl { get; } = new List<string>();
+    //
+    // [JsonPropertyName("template")]
+    // public string Template { get; set; }
+    //
+    // [JsonPropertyName("engines")]
+    // public List<string> Engines { get; } = new List<string>();
+    //
+    // [JsonPropertyName("positions")]
+    // public List<int> Positions { get; } = new List<int>();
+    //
+    // [JsonPropertyName("score")]
+    // public double Score { get; set; }
+    //
+    // [JsonPropertyName("category")]
+    // public string Category { get; set; }
+    //
+    // [JsonPropertyName("pretty_url")]
+    // public string PrettyUrl { get; set; }
+    //
+    // [JsonPropertyName("open_group")]
+    // public bool OpenGroup { get; set; }
+    //
+    // [JsonPropertyName("close_group")]
+    // public bool? CloseGroup { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs
new file mode 100644
index 0000000..33b8077
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchResultInformation.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.Searches;
+
+public sealed class SearxSearchResultInformation : ISearchResultInformation
+{
+    public string TotalResults { get; init; } = string.Empty;
+    public string SearchTime { get; init; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs
new file mode 100644
index 0000000..780c985
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxSearchService.cs
@@ -0,0 +1,77 @@
+using MorseCode.ITask;
+using System.Diagnostics;
+using System.Globalization;
+using System.Text.Json;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class SearxSearchService : SearchServiceBase, INService
+{
+    private readonly IHttpClientFactory _http;
+    private readonly SearchesConfigService _scs;
+    
+    private static readonly Random _rng = new EllieRandom();
+
+    public SearxSearchService(IHttpClientFactory http, SearchesConfigService scs)
+        => (_http, _scs) = (http, scs);
+
+    private string GetRandomInstance()
+    {
+        var instances = _scs.Data.SearxInstances;
+
+        if (instances is null or { Count: 0 })
+            throw new InvalidOperationException("No searx instances specified in searches.yml");
+
+        return instances[_rng.Next(0, instances.Count)];
+    }
+    
+    public override async ITask<SearxSearchResult> SearchAsync(string? query)
+    {
+        ArgumentNullException.ThrowIfNull(query);
+
+        var instanceUrl = GetRandomInstance();
+        
+        Log.Information("Using {Instance} instance for web search...", instanceUrl);
+        var startTime = Stopwatch.GetTimestamp();
+        
+        using var http = _http.CreateClient();
+        await using var res = await http.GetStreamAsync($"{instanceUrl}"
+                                                        + $"?q={Uri.EscapeDataString(query)}"
+                                                        + $"&format=json"
+                                                        + $"&strict=2");
+
+        var elapsed = Stopwatch.GetElapsedTime(startTime);
+        var dat = await JsonSerializer.DeserializeAsync<SearxSearchResult>(res);
+
+        if (dat is null)
+            return new SearxSearchResult();
+        
+        dat.SearchTime = elapsed.TotalSeconds.ToString("N2", CultureInfo.InvariantCulture); 
+        return dat;
+    }
+
+    public override async ITask<SearxImageSearchResult> SearchImagesAsync(string query)
+    {
+        ArgumentNullException.ThrowIfNull(query);
+        
+        var instanceUrl = GetRandomInstance();
+        
+        Log.Information("Using {Instance} instance for img search...", instanceUrl);
+        var startTime = Stopwatch.GetTimestamp();
+        using var http = _http.CreateClient();
+        await using var res = await http.GetStreamAsync($"{instanceUrl}"
+                                                        + $"?q={Uri.EscapeDataString(query)}"
+                                                        + $"&format=json"
+                                                        + $"&category_images=on"
+                                                        + $"&strict=2");
+
+        var elapsed = Stopwatch.GetElapsedTime(startTime);
+        var dat = await JsonSerializer.DeserializeAsync<SearxImageSearchResult>(res);
+
+        if (dat is null)
+            return new SearxImageSearchResult();
+        
+        dat.SearchTime = elapsed.TotalSeconds.ToString("N2", CultureInfo.InvariantCulture); 
+        return dat;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs b/src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs
new file mode 100644
index 0000000..07f8591
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Searx/SearxUrlData.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class SearxUrlData
+{
+    [JsonPropertyName("title")]
+    public string Title { get; set; } = null!;
+
+    [JsonPropertyName("url")]
+    public string Url { get; set; } = null!;
+
+    [JsonPropertyName("official")]
+    public bool? Official { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs
new file mode 100644
index 0000000..5b9bfab
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Youtube/IYoutubeSearchService.cs
@@ -0,0 +1,6 @@
+namespace EllieBot.Modules.Searches.Youtube;
+
+public interface IYoutubeSearchService
+{
+    Task<VideoInfo?> SearchAsync(string query);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs
new file mode 100644
index 0000000..d9a3f89
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousSearchResponse.cs
@@ -0,0 +1,63 @@
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class InvidiousSearchResponse
+{
+    [JsonPropertyName("videoId")]
+    public required string VideoId { get; init; } 
+    
+    [JsonPropertyName("title")]
+    public required string Title { get; init; }
+    
+    [JsonPropertyName("videoThumbnails")]
+    public required List<InvidiousThumbnail> Thumbnails { get; init; }
+    
+    [JsonPropertyName("lengthSeconds")]
+    public required int LengthSeconds { get; init; }
+    
+    [JsonPropertyName("description")]
+    public required string Description { get; init; }
+}
+
+public sealed class InvidiousVideoResponse
+{
+    [JsonPropertyName("title")]
+    public required string Title { get; init; }
+    
+    [JsonPropertyName("videoId")]
+    public required string VideoId { get; init; }
+    
+    [JsonPropertyName("lengthSeconds")]
+    public required int LengthSeconds { get; init; }
+
+    [JsonPropertyName("videoThumbnails")]
+    public required List<InvidiousThumbnail> Thumbnails { get; init; }
+    
+    [JsonPropertyName("adaptiveFormats")]
+    public required List<InvidiousAdaptiveFormat> AdaptiveFormats { get; init; }
+}
+
+public sealed class InvidiousAdaptiveFormat
+{
+    [JsonPropertyName("url")]
+    public required string Url { get; init; }
+    
+    [JsonPropertyName("audioQuality")]
+    public string? AudioQuality { get; init; }
+}
+
+public sealed class InvidiousPlaylistResponse
+{
+    [JsonPropertyName("title")]
+    public required string Title { get; init; }
+    
+    [JsonPropertyName("videos")]
+    public required List<InvidiousVideoResponse> Videos { get; init; }
+}
+
+public sealed class InvidiousThumbnail
+{
+    [JsonPropertyName("url")]
+    public required string Url { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs
new file mode 100644
index 0000000..93ebe03
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Youtube/InvidiousYtSearchService.cs
@@ -0,0 +1,47 @@
+using EllieBot.Modules.Searches.Youtube;
+using System.Net.Http.Json;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class InvidiousYtSearchService : IYoutubeSearchService, INService
+{
+    private readonly IHttpClientFactory _http;
+    private readonly SearchesConfigService _scs;
+    private readonly EllieRandom _rng;
+
+    public InvidiousYtSearchService(
+        IHttpClientFactory http,
+        SearchesConfigService scs)
+    {
+        _http = http;
+        _scs = scs;
+        _rng = new();
+    }
+
+    public async Task<VideoInfo?> SearchAsync(string query)
+    {
+        ArgumentNullException.ThrowIfNull(query);
+
+        var instances = _scs.Data.InvidiousInstances;
+        if (instances is null or { Count: 0 })
+        {
+            Log.Warning("Attempted to use Invidious as the .youtube provider but there are no 'invidiousInstances' "
+                        + "specified in `data/searches.yml`");
+            return null;
+        }
+
+        var instance = instances[_rng.Next(0, instances.Count)];
+
+        var url = $"{instance}/api/v1/search"
+                  + $"?q={query}"
+                  + $"&type=video";
+        using var http = _http.CreateClient();
+        var res = await http.GetFromJsonAsync<List<InvidiousSearchResponse>>(
+            url);
+
+        if (res is null or { Count: 0 })
+            return null;
+
+        return new VideoInfo(res[0].VideoId);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs b/src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs
new file mode 100644
index 0000000..5f53b9b
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Youtube/VideoInfo.cs
@@ -0,0 +1,9 @@
+namespace EllieBot.Modules.Searches.Youtube;
+
+public readonly struct VideoInfo
+{
+    public VideoInfo(string videoId)
+        => Url = $"https://youtube.com/watch?v={videoId}";
+
+    public string Url { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs
new file mode 100644
index 0000000..d82387a
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Youtube/YoutubeDataApiSearchService.cs
@@ -0,0 +1,26 @@
+namespace EllieBot.Modules.Searches.Youtube;
+
+public sealed class YoutubeDataApiSearchService : IYoutubeSearchService, INService
+{
+    private readonly IGoogleApiService _gapi;
+
+    public YoutubeDataApiSearchService(IGoogleApiService gapi)
+    {
+        _gapi = gapi;
+    }
+
+    public async Task<VideoInfo?> SearchAsync(string query)
+    {
+        ArgumentNullException.ThrowIfNull(query);
+
+        var results = await _gapi.GetVideoLinksByKeywordAsync(query);
+        var first = results.FirstOrDefault();
+        if (first is null)
+            return null;
+
+        return new()
+        {
+            Url = first
+        };
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs
new file mode 100644
index 0000000..ececc4b
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlYoutubeSearchService.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.Searches.Youtube;
+
+public sealed class YtdlYoutubeSearchService : YoutubedlxServiceBase, INService
+{
+    public override async Task<VideoInfo?> SearchAsync(string query)
+        => await InternalGetInfoAsync(query, false);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs
new file mode 100644
index 0000000..9eca5a7
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlpYoutubeSearchService.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.Searches.Youtube;
+
+public sealed class YtdlpYoutubeSearchService : YoutubedlxServiceBase, INService
+{
+    public override async Task<VideoInfo?> SearchAsync(string query)
+        => await InternalGetInfoAsync(query, true);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs
new file mode 100644
index 0000000..6239bdd
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Search/Youtube/YtdlxServiceBase.cs
@@ -0,0 +1,34 @@
+namespace EllieBot.Modules.Searches.Youtube;
+
+public abstract class YoutubedlxServiceBase : IYoutubeSearchService
+{
+    private YtdlOperation CreateYtdlOp(bool isYtDlp)
+        => new YtdlOperation("-4 "
+                             + "--geo-bypass "
+                             + "--encoding UTF8 "
+                             + "--get-id "
+                             + "--no-check-certificate "
+                             + "--default-search "
+                             + "\"ytsearch:\" -- \"{0}\"",
+            isYtDlp: isYtDlp);
+
+    protected async Task<VideoInfo?> InternalGetInfoAsync(string query, bool isYtDlp)
+    {
+        var op = CreateYtdlOp(isYtDlp);
+        var data = await op.GetDataAsync(query);
+        var items = data?.Split('\n');
+        if (items is null or { Length: 0 })
+            return null;
+
+        var id = items.FirstOrDefault(x => x.Length is > 5 and < 15);
+        if (id is null)
+            return null;
+
+        return new VideoInfo()
+        {
+            Url = $"https://youtube.com/watch?v={id}"
+        };
+    }
+
+    public abstract Task<VideoInfo?> SearchAsync(string query);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Searches.cs b/src/EllieBot/Modules/Searches/Searches.cs
new file mode 100644
index 0000000..70e2556
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Searches.cs
@@ -0,0 +1,461 @@
+using Microsoft.Extensions.Caching.Memory;
+using EllieBot.Modules.Searches.Common;
+using EllieBot.Modules.Searches.Services;
+using Newtonsoft.Json;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Drawing.Processing;
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp.Processing;
+using System.Diagnostics.CodeAnalysis;
+using Color = SixLabors.ImageSharp.Color;
+
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches : EllieModule<SearchesService>
+{
+    private readonly IBotCredentials _creds;
+    private readonly IGoogleApiService _google;
+    private readonly IHttpClientFactory _httpFactory;
+    private readonly IMemoryCache _cache;
+    private readonly ITimezoneService _tzSvc;
+
+    public Searches(
+        IBotCredentials creds,
+        IGoogleApiService google,
+        IHttpClientFactory factory,
+        IMemoryCache cache,
+        ITimezoneService tzSvc)
+    {
+        _creds = creds;
+        _google = google;
+        _httpFactory = factory;
+        _cache = cache;
+        _tzSvc = tzSvc;
+    }
+
+    [Cmd]
+    public async Task Weather([Leftover] string query)
+    {
+        if (!await ValidateQuery(query))
+            return;
+
+        var embed = _sender.CreateEmbed();
+        var data = await _service.GetWeatherDataAsync(query);
+
+        if (data is null)
+            embed.WithDescription(GetText(strs.city_not_found)).WithErrorColor();
+        else
+        {
+            var f = StandardConversions.CelsiusToFahrenheit;
+
+            var tz = _tzSvc.GetTimeZoneOrUtc(ctx.Guild?.Id);
+            var sunrise = data.Sys.Sunrise.ToUnixTimestamp();
+            var sunset = data.Sys.Sunset.ToUnixTimestamp();
+            sunrise = sunrise.ToOffset(tz.GetUtcOffset(sunrise));
+            sunset = sunset.ToOffset(tz.GetUtcOffset(sunset));
+            var timezone = $"UTC{sunrise:zzz}";
+
+            embed
+                .AddField("🌍 " + Format.Bold(GetText(strs.location)),
+                    $"[{data.Name + ", " + data.Sys.Country}](https://openweathermap.org/city/{data.Id})",
+                    true)
+                .AddField("📏 " + Format.Bold(GetText(strs.latlong)), $"{data.Coord.Lat}, {data.Coord.Lon}", true)
+                .AddField("☁ " + Format.Bold(GetText(strs.condition)),
+                    string.Join(", ", data.Weather.Select(w => w.Main)),
+                    true)
+                .AddField("😓 " + Format.Bold(GetText(strs.humidity)), $"{data.Main.Humidity}%", true)
+                .AddField("💨 " + Format.Bold(GetText(strs.wind_speed)), data.Wind.Speed + " m/s", true)
+                .AddField("🌡 " + Format.Bold(GetText(strs.temperature)),
+                    $"{data.Main.Temp:F1}°C / {f(data.Main.Temp):F1}°F",
+                    true)
+                .AddField("🔆 " + Format.Bold(GetText(strs.min_max)),
+                    $"{data.Main.TempMin:F1}°C - {data.Main.TempMax:F1}°C\n{f(data.Main.TempMin):F1}°F - {f(data.Main.TempMax):F1}°F",
+                    true)
+                .AddField("🌄 " + Format.Bold(GetText(strs.sunrise)), $"{sunrise:HH:mm} {timezone}", true)
+                .AddField("🌇 " + Format.Bold(GetText(strs.sunset)), $"{sunset:HH:mm} {timezone}", true)
+                .WithOkColor()
+                .WithFooter("Powered by openweathermap.org",
+                    $"https://openweathermap.org/img/w/{data.Weather[0].Icon}.png");
+        }
+
+        await Response().Embed(embed).SendAsync();
+    }
+
+    [Cmd]
+    public async Task Time([Leftover] string query)
+    {
+        if (!await ValidateQuery(query))
+            return;
+
+        await ctx.Channel.TriggerTypingAsync();
+
+        var (data, err) = await _service.GetTimeDataAsync(query);
+        if (err is not null)
+        {
+            await HandleErrorAsync(err.Value);
+            return;
+        }
+
+        if (string.IsNullOrWhiteSpace(data.TimeZoneName))
+        {
+            await Response().Error(strs.timezone_db_api_key).SendAsync();
+            return;
+        }
+
+        var eb = _sender.CreateEmbed()
+                        .WithOkColor()
+                        .WithTitle(GetText(strs.time_new))
+                        .WithDescription(Format.Code(data.Time.ToString(Culture)))
+                        .AddField(GetText(strs.location), string.Join('\n', data.Address.Split(", ")), true)
+                        .AddField(GetText(strs.timezone), data.TimeZoneName, true);
+
+        await Response().Embed(eb).SendAsync();
+    }
+
+    [Cmd]
+    public async Task Movie([Leftover] string query)
+    {
+        if (!await ValidateQuery(query))
+            return;
+
+        await ctx.Channel.TriggerTypingAsync();
+
+        var movie = await _service.GetMovieDataAsync(query);
+        if (movie is null)
+        {
+            await Response().Error(strs.imdb_fail).SendAsync();
+            return;
+        }
+
+        await Response()
+              .Embed(_sender.CreateEmbed()
+                            .WithOkColor()
+                            .WithTitle(movie.Title)
+                            .WithUrl($"https://www.imdb.com/title/{movie.ImdbId}/")
+                            .WithDescription(movie.Plot.TrimTo(1000))
+                            .AddField("Rating", movie.ImdbRating, true)
+                            .AddField("Genre", movie.Genre, true)
+                            .AddField("Year", movie.Year, true)
+                            .WithImageUrl(Uri.IsWellFormedUriString(movie.Poster, UriKind.Absolute)
+                                ? movie.Poster
+                                : null))
+              .SendAsync();
+    }
+
+    [Cmd]
+    public Task RandomCat()
+        => InternalRandomImage(SearchesService.ImageTag.Cats);
+
+    [Cmd]
+    public Task RandomDog()
+        => InternalRandomImage(SearchesService.ImageTag.Dogs);
+
+    [Cmd]
+    public Task RandomFood()
+        => InternalRandomImage(SearchesService.ImageTag.Food);
+
+    [Cmd]
+    public Task RandomBird()
+        => InternalRandomImage(SearchesService.ImageTag.Birds);
+
+    private Task InternalRandomImage(SearchesService.ImageTag tag)
+    {
+        var url = _service.GetRandomImageUrl(tag);
+        return Response().Embed(_sender.CreateEmbed().WithOkColor().WithImageUrl(url)).SendAsync();
+    }
+
+    [Cmd]
+    public async Task Lmgtfy([Leftover] string smh)
+    {
+        if (!await ValidateQuery(smh))
+            return;
+
+        var link = $"https://letmegooglethat.com/?q={Uri.EscapeDataString(smh)}";
+        var shortenedUrl = await _service.ShortenLink(link) ?? link;
+        await Response().Confirm($"<{shortenedUrl}>").SendAsync();
+    }
+
+    [Cmd]
+    public async Task Shorten([Leftover] string query)
+    {
+        if (!await ValidateQuery(query))
+            return;
+
+        var shortLink = await _service.ShortenLink(query);
+
+        if (shortLink is null)
+        {
+            await Response().Error(strs.error_occured).SendAsync();
+            return;
+        }
+
+        await Response()
+              .Embed(_sender.CreateEmbed()
+                            .WithOkColor()
+                            .AddField(GetText(strs.original_url), $"<{query}>")
+                            .AddField(GetText(strs.short_url), $"<{shortLink}>"))
+              .SendAsync();
+    }
+
+
+    [Cmd]
+    public async Task MagicTheGathering([Leftover] string search)
+    {
+        if (!await ValidateQuery(search))
+            return;
+
+        await ctx.Channel.TriggerTypingAsync();
+        var card = await _service.GetMtgCardAsync(search);
+
+        if (card is null)
+        {
+            await Response().Error(strs.card_not_found).SendAsync();
+            return;
+        }
+
+        var embed = _sender.CreateEmbed()
+                           .WithOkColor()
+                           .WithTitle(card.Name)
+                           .WithDescription(card.Description)
+                           .WithImageUrl(card.ImageUrl)
+                           .AddField(GetText(strs.store_url), card.StoreUrl, true)
+                           .AddField(GetText(strs.cost), card.ManaCost, true)
+                           .AddField(GetText(strs.types), card.Types, true);
+
+        await Response().Embed(embed).SendAsync();
+    }
+
+    [Cmd]
+    public async Task Hearthstone([Leftover] string name)
+    {
+        if (!await ValidateQuery(name))
+            return;
+
+        if (string.IsNullOrWhiteSpace(_creds.RapidApiKey))
+        {
+            await Response().Error(strs.mashape_api_missing).SendAsync();
+            return;
+        }
+
+        await ctx.Channel.TriggerTypingAsync();
+        var card = await _service.GetHearthstoneCardDataAsync(name);
+
+        if (card is null)
+        {
+            await Response().Error(strs.card_not_found).SendAsync();
+            return;
+        }
+
+        var embed = _sender.CreateEmbed().WithOkColor().WithImageUrl(card.Img);
+
+        if (!string.IsNullOrWhiteSpace(card.Flavor))
+            embed.WithDescription(card.Flavor);
+
+        await Response().Embed(embed).SendAsync();
+    }
+
+    [Cmd]
+    public async Task UrbanDict([Leftover] string query)
+    {
+        if (!await ValidateQuery(query))
+            return;
+
+        await ctx.Channel.TriggerTypingAsync();
+        using var http = _httpFactory.CreateClient();
+        var res = await http.GetStringAsync($"https://api.urbandictionary.com/v0/define?"
+                                            + $"term={Uri.EscapeDataString(query)}");
+        var allItems = JsonConvert.DeserializeObject<UrbanResponse>(res)?.List;
+
+        if (allItems is null or { Length: 0 })
+        {
+            await Response().Error(strs.ud_error).SendAsync();
+            return;
+        }
+
+        await Response()
+              .Paginated()
+              .Items(allItems)
+              .PageSize(1)
+              .CurrentPage(0)
+              .Page((items, _) =>
+              {
+                  var item = items[0];
+                  return _sender.CreateEmbed()
+                                .WithOkColor()
+                                .WithUrl(item.Permalink)
+                                .WithTitle(item.Word)
+                                .WithDescription(item.Definition);
+              })
+              .SendAsync();
+    }
+
+    [Cmd]
+    public async Task Define([Leftover] string word)
+    {
+        if (!await ValidateQuery(word))
+            return;
+
+
+        var maybeItems = await _service.GetDefinitionsAsync(word);
+
+        if (!maybeItems.TryPickT0(out var defs, out var error))
+        {
+            await HandleErrorAsync(error);
+            return;
+        }
+
+        await Response()
+              .Paginated()
+              .Items(defs)
+              .PageSize(1)
+              .Page((items, _) =>
+              {
+                  var model = items.First();
+                  var embed = _sender.CreateEmbed()
+                                     .WithDescription(ctx.User.Mention)
+                                     .AddField(GetText(strs.word), model.Word, true)
+                                     .AddField(GetText(strs._class), model.WordType, true)
+                                     .AddField(GetText(strs.definition), model.Definition)
+                                     .WithOkColor();
+
+                  if (!string.IsNullOrWhiteSpace(model.Example))
+                      embed.AddField(GetText(strs.example), model.Example);
+
+                  return embed;
+              })
+              .SendAsync();
+    }
+
+    [Cmd]
+    public async Task Catfact()
+    {
+        var maybeFact = await _service.GetCatFactAsync();
+
+        if (!maybeFact.TryPickT0(out var fact, out var error))
+        {
+            await HandleErrorAsync(error);
+            return;
+        }
+
+        await Response().Confirm("🐈" + GetText(strs.catfact), fact).SendAsync();
+    }
+
+    [Cmd]
+    public async Task Wiki([Leftover] string query)
+    {
+        query = query.Trim();
+
+        if (!await ValidateQuery(query))
+            return;
+
+        var maybeRes = await _service.GetWikipediaPageAsync(query);
+        if (!maybeRes.TryPickT0(out var res, out var error))
+        {
+            await HandleErrorAsync(error);
+            return;
+        }
+
+        var data = res.Data;
+        await Response().Text(data.Url).SendAsync();
+    }
+
+    public Task<IUserMessage> HandleErrorAsync(ErrorType error)
+    {
+        var errorKey = error switch
+        {
+            ErrorType.ApiKeyMissing => strs.api_key_missing,
+            ErrorType.InvalidInput => strs.invalid_input,
+            ErrorType.NotFound => strs.not_found,
+            ErrorType.Unknown => strs.error_occured,
+            _ => strs.error_occured,
+        };
+
+        return Response().Error(errorKey).SendAsync();
+    }
+
+    [Cmd]
+    public async Task Color(params Color[] colors)
+    {
+        if (!colors.Any())
+            return;
+
+        var colorObjects = colors.Take(10).ToArray();
+
+        using var img = new Image<Rgba32>(colorObjects.Length * 50, 50);
+        for (var i = 0; i < colorObjects.Length; i++)
+        {
+            var x = i * 50;
+            var j = i;
+            img.Mutate(m => m.FillPolygon(colorObjects[j], new(x, 0), new(x + 50, 0), new(x + 50, 50), new(x, 50)));
+        }
+
+        await using var ms = await img.ToStreamAsync();
+        await ctx.Channel.SendFileAsync(ms, "colors.png");
+    }
+
+    [Cmd]
+    [RequireContext(ContextType.Guild)]
+    public async Task Avatar([Leftover] IGuildUser? usr = null)
+    {
+        usr ??= (IGuildUser)ctx.User;
+
+        var avatarUrl = usr.RealAvatarUrl(2048);
+
+        await Response()
+              .Embed(
+                  _sender.CreateEmbed()
+                         .WithOkColor()
+                         .AddField("Username", usr.ToString())
+                         .AddField("Avatar Url", avatarUrl)
+                         .WithThumbnailUrl(avatarUrl.ToString()))
+              .SendAsync();
+    }
+
+    [Cmd]
+    public async Task Wikia(string target, [Leftover] string query)
+    {
+        if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(query))
+        {
+            await Response().Error(strs.wikia_input_error).SendAsync();
+            return;
+        }
+
+        var maybeRes = await _service.GetWikiaPageAsync(target, query);
+
+        if (!maybeRes.TryPickT0(out var res, out var error))
+        {
+            await HandleErrorAsync(error);
+            return;
+        }
+
+        var response = $"### {res.Title}\n{res.Url}";
+        await Response().Text(response).Sanitize().SendAsync();
+    }
+
+    [Cmd]
+    public async Task Steam([Leftover] string query)
+    {
+        if (string.IsNullOrWhiteSpace(query))
+            return;
+
+        await ctx.Channel.TriggerTypingAsync();
+
+        var appId = await _service.GetSteamAppIdByName(query);
+        if (appId == -1)
+        {
+            await Response().Error(strs.not_found).SendAsync();
+            return;
+        }
+
+        await Response().Text($"https://store.steampowered.com/app/{appId}").SendAsync();
+    }
+
+    private async Task<bool> ValidateQuery([MaybeNullWhen(false)] string query)
+    {
+        if (!string.IsNullOrWhiteSpace(query))
+            return true;
+
+        await Response().Error(strs.specify_search_params).SendAsync();
+        return false;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/SearchesService.cs b/src/EllieBot/Modules/Searches/SearchesService.cs
new file mode 100644
index 0000000..5767240
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/SearchesService.cs
@@ -0,0 +1,616 @@
+#nullable disable
+using EllieBot.Modules.Searches.Common;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using OneOf;
+using System.Text.Json;
+
+namespace EllieBot.Modules.Searches.Services;
+
+public class SearchesService : INService
+{
+    public enum ImageTag
+    {
+        Food,
+        Dogs,
+        Cats,
+        Birds
+    }
+
+    public List<WoWJoke> WowJokes { get; } = [];
+    public List<MagicItem> MagicItems { get; } = [];
+    private readonly IHttpClientFactory _httpFactory;
+    private readonly IGoogleApiService _google;
+    private readonly IBotCache _c;
+    private readonly IBotCredsProvider _creds;
+    private readonly EllieRandom _rng;
+    private readonly List<string> _yomamaJokes;
+
+    private readonly object _yomamaLock = new();
+    private int yomamaJokeIndex;
+    private readonly ConcurrentDictionary<string, string> _cachedShortenedLinks = new();
+
+    public SearchesService(
+        IGoogleApiService google,
+        IBotCache c,
+        IHttpClientFactory factory,
+        FontProvider fonts,
+        IBotCredsProvider creds)
+    {
+        _httpFactory = factory;
+        _google = google;
+        _c = c;
+        _creds = creds;
+        _rng = new();
+
+        //joke commands
+        if (File.Exists("data/wowjokes.json"))
+            WowJokes = JsonConvert.DeserializeObject<List<WoWJoke>>(File.ReadAllText("data/wowjokes.json"));
+        else
+            Log.Warning("data/wowjokes.json is missing. WOW Jokes are not loaded");
+
+        if (File.Exists("data/magicitems.json"))
+            MagicItems = JsonConvert.DeserializeObject<List<MagicItem>>(File.ReadAllText("data/magicitems.json"));
+        else
+            Log.Warning("data/magicitems.json is missing. Magic items are not loaded");
+
+        if (File.Exists("data/yomama.txt"))
+            _yomamaJokes = File.ReadAllLines("data/yomama.txt").Shuffle().ToList();
+        else
+        {
+            _yomamaJokes = [];
+            Log.Warning("data/yomama.txt is missing. .yomama command won't work");
+        }
+    }
+
+    public async Task<WeatherData> GetWeatherDataAsync(string query)
+    {
+        query = query.Trim().ToLowerInvariant();
+
+        return await _c.GetOrAddAsync(new($"ellie_weather_{query}"),
+            async () => await GetWeatherDataFactory(query),
+            TimeSpan.FromHours(3));
+    }
+
+    private async Task<WeatherData> GetWeatherDataFactory(string query)
+    {
+        using var http = _httpFactory.CreateClient();
+        try
+        {
+            var data = await http.GetStringAsync("https://api.openweathermap.org/data/2.5/weather?"
+                                                 + $"q={query}&"
+                                                 + "appid=42cd627dd60debf25a5739e50a217d74&"
+                                                 + "units=metric");
+
+            if (string.IsNullOrWhiteSpace(data))
+                return null;
+
+            return JsonConvert.DeserializeObject<WeatherData>(data);
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "Error getting weather data");
+            return null;
+        }
+    }
+
+    public Task<((string Address, DateTime Time, string TimeZoneName), ErrorType?)> GetTimeDataAsync(string arg)
+        => GetTimeDataFactory(arg);
+
+    //return _cache.GetOrAddCachedDataAsync($"ellie_time_{arg}",
+    //    GetTimeDataFactory,
+    //    arg,
+    //    TimeSpan.FromMinutes(1));
+    private async Task<((string Address, DateTime Time, string TimeZoneName), ErrorType?)> GetTimeDataFactory(
+        string query)
+    {
+        query = query.Trim();
+
+        if (string.IsNullOrEmpty(query))
+            return (default, ErrorType.InvalidInput);
+
+
+        var locIqKey = _creds.GetCreds().LocationIqApiKey;
+        var tzDbKey = _creds.GetCreds().TimezoneDbApiKey;
+        if (string.IsNullOrWhiteSpace(locIqKey) || string.IsNullOrWhiteSpace(tzDbKey))
+            return (default, ErrorType.ApiKeyMissing);
+
+        try
+        {
+            using var http = _httpFactory.CreateClient();
+            var res = await _c.GetOrAddAsync(new($"searches:geo:{query}"),
+                async () =>
+                {
+                    var url = "https://eu1.locationiq.com/v1/search.php?"
+                              + (string.IsNullOrWhiteSpace(locIqKey)
+                                  ? "key="
+                                  : $"key={locIqKey}&")
+                              + $"q={Uri.EscapeDataString(query)}&"
+                              + "format=json";
+
+                    var res = await http.GetStringAsync(url);
+                    return res;
+                },
+                TimeSpan.FromHours(1));
+
+            var responses = JsonConvert.DeserializeObject<LocationIqResponse[]>(res);
+            if (responses is null || responses.Length == 0)
+            {
+                Log.Warning("Geocode lookup failed for: {Query}", query);
+                return (default, ErrorType.NotFound);
+            }
+
+            var geoData = responses[0];
+
+            using var req = new HttpRequestMessage(HttpMethod.Get,
+                "http://api.timezonedb.com/v2.1/get-time-zone?"
+                + $"key={tzDbKey}"
+                + $"&format=json"
+                + $"&by=position"
+                + $"&lat={geoData.Lat}"
+                + $"&lng={geoData.Lon}");
+
+            using var geoRes = await http.SendAsync(req);
+            var resString = await geoRes.Content.ReadAsStringAsync();
+            var timeObj = JsonConvert.DeserializeObject<TimeZoneResult>(resString);
+
+            var time = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddSeconds(timeObj.Timestamp);
+
+            return ((Address: responses[0].DisplayName, Time: time, TimeZoneName: timeObj.TimezoneName), default);
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Weather error: {Message}", ex.Message);
+            return (default, ErrorType.NotFound);
+        }
+    }
+
+    public string GetRandomImageUrl(ImageTag tag)
+    {
+        var subpath = tag.ToString().ToLowerInvariant();
+
+        var max = tag switch
+        {
+            ImageTag.Food => 773,
+            ImageTag.Dogs => 750,
+            ImageTag.Cats => 773,
+            ImageTag.Birds => 578,
+            _ => 100,
+        };
+
+
+        return $"https://ellie-pictures.nyc3.digitaloceanspaces.com/{subpath}/"
+               + _rng.Next(1, max).ToString("000")
+               + ".png";
+    }
+
+    public Task<string> GetYomamaJoke()
+    {
+        string joke;
+        lock (_yomamaLock)
+        {
+            if (yomamaJokeIndex >= _yomamaJokes.Count)
+            {
+                yomamaJokeIndex = 0;
+                var newList = _yomamaJokes.ToList();
+                _yomamaJokes.Clear();
+                _yomamaJokes.AddRange(newList.Shuffle());
+            }
+
+            joke = _yomamaJokes[yomamaJokeIndex++];
+        }
+
+        return Task.FromResult(joke);
+
+        // using (var http = _httpFactory.CreateClient())
+        // {
+        //     var response = await http.GetStringAsync(new Uri("http://api.yomomma.info/"));
+        //     return JObject.Parse(response)["joke"].ToString() + " 😆";
+        // }
+    }
+
+    public async Task<(string Setup, string Punchline)> GetRandomJoke()
+    {
+        using var http = _httpFactory.CreateClient();
+        var res = await http.GetStringAsync("https://official-joke-api.appspot.com/random_joke");
+        var resObj = JsonConvert.DeserializeAnonymousType(res,
+            new
+            {
+                setup = "",
+                punchline = ""
+            });
+        return (resObj.setup, resObj.punchline);
+    }
+
+    public async Task<string> GetChuckNorrisJoke()
+    {
+        using var http = _httpFactory.CreateClient();
+        var response = await http.GetStringAsync(new Uri("https://api.chucknorris.io/jokes/random"));
+        return JObject.Parse(response)["value"] + " 😆";
+    }
+
+    public async Task<MtgData> GetMtgCardAsync(string search)
+    {
+        search = search.Trim().ToLowerInvariant();
+        var data = await _c.GetOrAddAsync(new($"mtg:{search}"),
+            async () => await GetMtgCardFactory(search),
+            TimeSpan.FromDays(1));
+
+        if (data is null || data.Length == 0)
+            return null;
+
+        return data[_rng.Next(0, data.Length)];
+    }
+
+    private async Task<MtgData[]> GetMtgCardFactory(string search)
+    {
+        async Task<MtgData> GetMtgDataAsync(MtgResponse.Data card)
+        {
+            string storeUrl;
+            try
+            {
+                storeUrl = await _google.ShortenUrl("https://shop.tcgplayer.com/productcatalog/product/show?"
+                                                    + "newSearch=false&"
+                                                    + "ProductType=All&"
+                                                    + "IsProductNameExact=false&"
+                                                    + $"ProductName={Uri.EscapeDataString(card.Name)}");
+            }
+            catch { storeUrl = "<url can't be found>"; }
+
+            return new()
+            {
+                Description = card.Text,
+                Name = card.Name,
+                ImageUrl = card.ImageUrl,
+                StoreUrl = storeUrl,
+                Types = string.Join(",\n", card.Types),
+                ManaCost = card.ManaCost
+            };
+        }
+
+        using var http = _httpFactory.CreateClient();
+        http.DefaultRequestHeaders.Clear();
+        var response =
+            await http.GetStringAsync($"https://api.magicthegathering.io/v1/cards?name={Uri.EscapeDataString(search)}");
+
+        var responseObject = JsonConvert.DeserializeObject<MtgResponse>(response);
+        if (responseObject is null)
+            return Array.Empty<MtgData>();
+
+        var cards = responseObject.Cards.Take(5).ToArray();
+        if (cards.Length == 0)
+            return Array.Empty<MtgData>();
+
+        return await cards.Select(GetMtgDataAsync).WhenAll();
+    }
+
+    public async Task<HearthstoneCardData> GetHearthstoneCardDataAsync(string name)
+    {
+        name = name.ToLowerInvariant();
+        return await _c.GetOrAddAsync($"hearthstone:{name}",
+            () => HearthstoneCardDataFactory(name),
+            TimeSpan.FromDays(1));
+    }
+
+    private async Task<HearthstoneCardData> HearthstoneCardDataFactory(string name)
+    {
+        using var http = _httpFactory.CreateClient();
+        http.DefaultRequestHeaders.Clear();
+        http.DefaultRequestHeaders.Add("x-rapidapi-key", _creds.GetCreds().RapidApiKey);
+        try
+        {
+            var response = await http.GetStringAsync("https://omgvamp-hearthstone-v1.p.rapidapi.com/"
+                                                     + $"cards/search/{Uri.EscapeDataString(name)}");
+            var objs = JsonConvert.DeserializeObject<HearthstoneCardData[]>(response);
+            if (objs is null || objs.Length == 0)
+                return null;
+            var data = objs.FirstOrDefault(x => x.Collectible)
+                       ?? objs.FirstOrDefault(x => !string.IsNullOrEmpty(x.PlayerClass)) ?? objs.FirstOrDefault();
+            if (data is null)
+                return null;
+            if (!string.IsNullOrWhiteSpace(data.Img))
+                data.Img = await _google.ShortenUrl(data.Img);
+            // if (!string.IsNullOrWhiteSpace(data.Text))
+            // {
+            //     var converter = new Converter();
+            //     data.Text = converter.Convert(data.Text);
+            // }
+
+            return data;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Error getting Hearthstone Card: {ErrorMessage}", ex.Message);
+            return null;
+        }
+    }
+
+    public async Task<OmdbMovie> GetMovieDataAsync(string name)
+    {
+        name = name.Trim().ToLowerInvariant();
+        return await _c.GetOrAddAsync(new($"movie:{name}"),
+            () => GetMovieDataFactory(name),
+            TimeSpan.FromDays(1));
+    }
+
+    private async Task<OmdbMovie> GetMovieDataFactory(string name)
+    {
+        using var http = _httpFactory.CreateClient();
+        var res = await http.GetStringAsync("https://omdbapi.nadeko.bot/"
+                                            + $"?t={name.Trim().Replace(' ', '+')}"
+                                            + "&y="
+                                            + "&plot=full"
+                                            + "&r=json");
+        var movie = JsonConvert.DeserializeObject<OmdbMovie>(res);
+        if (movie?.Title is null)
+            return null;
+        movie.Poster = await _google.ShortenUrl(movie.Poster);
+        return movie;
+    }
+
+    public async Task<int> GetSteamAppIdByName(string query)
+    {
+        const string steamGameIdsKey = "steam_names_to_appid";
+
+        var gamesMap = await _c.GetOrAddAsync(new(steamGameIdsKey),
+            async () =>
+            {
+                using var http = _httpFactory.CreateClient();
+
+                // https://api.steampowered.com/ISteamApps/GetAppList/v2/
+                var gamesStr = await http.GetStringAsync("https://api.steampowered.com/ISteamApps/GetAppList/v2/");
+                var apps = JsonConvert
+                           .DeserializeAnonymousType(gamesStr,
+                               new
+                               {
+                                   applist = new
+                                   {
+                                       apps = new List<SteamGameId>()
+                                   }
+                               })!
+                           .applist.apps;
+
+                return apps.OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase)
+                           .GroupBy(x => x.Name)
+                           .ToDictionary(x => x.Key, x => x.First().AppId);
+            },
+            TimeSpan.FromHours(24));
+
+        if (gamesMap is null)
+            return -1;
+
+        query = query.Trim();
+
+        var keyList = gamesMap.Keys.ToList();
+
+        var key = keyList.FirstOrDefault(x => x.Equals(query, StringComparison.OrdinalIgnoreCase));
+
+        if (key == default)
+        {
+            key = keyList.FirstOrDefault(x => x.StartsWith(query, StringComparison.OrdinalIgnoreCase));
+            if (key == default)
+                return -1;
+        }
+
+        return gamesMap[key];
+    }
+
+    public async Task<OneOf<WikipediaReply, ErrorType>> GetWikipediaPageAsync(string query)
+    {
+        query = query.Trim();
+        if (string.IsNullOrEmpty(query))
+        {
+            return ErrorType.InvalidInput;
+        }
+
+        try
+        {
+            var result = await _c.GetOrAddAsync($"wikipedia_{query}",
+                                     async () =>
+                                     {
+                                         using var http = _httpFactory.CreateClient();
+                                         http.DefaultRequestHeaders.Clear();
+
+                                         return await http.GetStringAsync(
+                                             "https://en.wikipedia.org/w/api.php?action=query"
+                                             + "&format=json"
+                                             + "&prop=info"
+                                             + "&redirects=1"
+                                             + "&formatversion=2"
+                                             + "&inprop=url"
+                                             + "&titles="
+                                             + Uri.EscapeDataString(query));
+                                     },
+                                     TimeSpan.FromHours(1))
+                                 .ConfigureAwait(false);
+
+            var data = JsonConvert.DeserializeObject<WikipediaApiModel>(result);
+
+            if (data.Query.Pages is null || !data.Query.Pages.Any() || data.Query.Pages.First().Missing)
+            {
+                return ErrorType.NotFound;
+            }
+
+            Log.Information("Sending wikipedia url for: {Query}", query);
+
+            return new WikipediaReply
+            {
+                Data = new()
+                {
+                    Url = data.Query.Pages[0].FullUrl,
+                }
+            };
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Error retrieving wikipedia data for: '{Query}'", query);
+
+            return ErrorType.Unknown;
+        }
+    }
+
+    public async Task<OneOf<string, ErrorType>> GetCatFactAsync()
+    {
+        using var http = _httpFactory.CreateClient();
+        var response = await http.GetStringAsync("https://catfact.ninja/fact").ConfigureAwait(false);
+
+        var doc = JsonDocument.Parse(response);
+
+
+        if (!doc.RootElement.TryGetProperty("fact", out var factElement))
+        {
+            return ErrorType.Unknown;
+        }
+
+        return factElement.ToString();
+    }
+
+    public async Task<OneOf<WikiaResponse, ErrorType>> GetWikiaPageAsync(string target, string query)
+    {
+        if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(query))
+        {
+            return ErrorType.InvalidInput;
+        }
+
+        query = Uri.EscapeDataString(query.Trim());
+        target = Uri.EscapeDataString(target.Trim());
+
+        if (string.IsNullOrEmpty(query))
+        {
+            return ErrorType.InvalidInput;
+        }
+
+        using var http = _httpFactory.CreateClient();
+        http.DefaultRequestHeaders.Clear();
+        try
+        {
+            var res = await http.GetStringAsync($"https://{Uri.EscapeDataString(target)}.fandom.com/api.php"
+                                                + "?action=query"
+                                                + "&format=json"
+                                                + "&list=search"
+                                                + $"&srsearch={Uri.EscapeDataString(query)}"
+                                                + "&srlimit=1");
+            var items = JObject.Parse(res);
+            var title = items["query"]?["search"]?.FirstOrDefault()?["title"]?.ToString();
+
+            if (string.IsNullOrWhiteSpace(title))
+            {
+                return ErrorType.NotFound;
+            }
+
+            var url = $"https://{target}.fandom.com/wiki/{title}";
+
+            return new WikiaResponse()
+            {
+                Url = url,
+                Title = title,
+            };
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "Error getting wikia page: {Message}", ex.Message);
+            return ErrorType.Unknown;
+        }
+    }
+
+    private static TypedKey<string> GetDefineKey(string query)
+        => new TypedKey<string>($"define_{query}");
+
+    public async Task<OneOf<List<DefineData>, ErrorType>> GetDefinitionsAsync(string query)
+    {
+        if (string.IsNullOrWhiteSpace(query))
+        {
+            return ErrorType.InvalidInput;
+        }
+
+        query = Uri.EscapeDataString(query);
+
+        using var http = _httpFactory.CreateClient();
+        string res;
+        try
+        {
+            res = await _c.GetOrAddAsync(GetDefineKey(query),
+                async () => await http.GetStringAsync(
+                    $"https://api.pearson.com/v2/dictionaries/entries?headword={query}"),
+                TimeSpan.FromHours(12));
+
+            var responseModel = JsonConvert.DeserializeObject<DefineModel>(res);
+
+            var data = responseModel.Results
+                                    .Where(x => x.Senses is not null
+                                                && x.Senses.Count > 0
+                                                && x.Senses[0].Definition is not null)
+                                    .Select(x => (Sense: x.Senses[0], x.PartOfSpeech))
+                                    .ToList();
+
+            if (!data.Any())
+            {
+                Log.Warning("Definition not found: {Word}", query);
+                return ErrorType.NotFound;
+            }
+
+
+            var items = new List<DefineData>();
+
+            foreach (var d in data)
+            {
+                items.Add(new DefineData
+                {
+                    Definition = d.Sense.Definition is JArray { Count: > 0 } defs
+                        ? defs[0].ToString()
+                        : d.Sense.Definition.ToString(),
+                    Example = d.Sense.Examples is null || d.Sense.Examples.Count == 0
+                        ? string.Empty
+                        : d.Sense.Examples[0].Text,
+                    WordType = string.IsNullOrWhiteSpace(d.PartOfSpeech) ? "-" : d.PartOfSpeech,
+                    Word = query,
+                });
+            }
+
+            return items.OrderByDescending(x => !string.IsNullOrWhiteSpace(x.Example)).ToList();
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Error retrieving definition data for: {Word}", query);
+            return ErrorType.Unknown;
+        }
+    }
+
+    public async Task<string> ShortenLink(string query)
+    {
+        query = query.Trim();
+
+        if (_cachedShortenedLinks.TryGetValue(query, out var shortLink))
+            return shortLink;
+
+        try
+        {
+            using var http = _httpFactory.CreateClient();
+            using var req = new HttpRequestMessage(HttpMethod.Post, "https://goolnk.com/api/v1/shorten");
+            var formData = new MultipartFormDataContent
+            {
+                { new StringContent(query), "url" }
+            };
+            req.Content = formData;
+
+            using var res = await http.SendAsync(req);
+            var content = await res.Content.ReadAsStringAsync();
+            var data = JsonConvert.DeserializeObject<ShortenData>(content);
+
+            if (!string.IsNullOrWhiteSpace(data?.ResultUrl))
+                _cachedShortenedLinks.TryAdd(query, data.ResultUrl);
+            else
+                return query;
+
+            shortLink = data.ResultUrl;
+        }
+        catch (Exception ex)
+        {
+            Log.Error(ex, "Error shortening a link: {Message}", ex.Message);
+            return null;
+        }
+
+        return shortLink;
+        throw new NotImplementedException();
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs
new file mode 100644
index 0000000..ed260ae
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationCommands.cs
@@ -0,0 +1,195 @@
+#nullable disable
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Db.Models;
+using EllieBot.Modules.Searches.Services;
+
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches
+{
+    [Group]
+    public partial class StreamNotificationCommands : EllieModule<StreamNotificationService>
+    {
+        private readonly DbService _db;
+
+        public StreamNotificationCommands(DbService db)
+            => _db = db;
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        public async Task StreamAdd(string link)
+        {
+            var data = await _service.FollowStream(ctx.Guild.Id, ctx.Channel.Id, link);
+            if (data is null)
+            {
+                await Response().Error(strs.stream_not_added).SendAsync();
+                return;
+            }
+
+            var embed = _service.GetEmbed(ctx.Guild.Id, data);
+            await Response()
+                  .Embed(embed)
+                  .Text(strs.stream_tracked)
+                  .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        [Priority(1)]
+        public async Task StreamRemove(int index)
+        {
+            if (--index < 0)
+                return;
+
+            var fs = await _service.UnfollowStreamAsync(ctx.Guild.Id, index);
+            if (fs is null)
+            {
+                await Response().Error(strs.stream_no).SendAsync();
+                return;
+            }
+
+            await Response().Confirm(strs.stream_removed(Format.Bold(fs.Username), fs.Type)).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.Administrator)]
+        public async Task StreamsClear()
+        {
+            await _service.ClearAllStreams(ctx.Guild.Id);
+            await Response().Confirm(strs.streams_cleared).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task StreamList(int page = 1)
+        {
+            if (page-- < 1)
+                return;
+
+            var allStreams = await _service.GetAllStreamsAsync((SocketGuild)ctx.Guild);
+
+            await Response()
+                  .Paginated()
+                  .Items(allStreams)
+                  .PageSize(12)
+                  .CurrentPage(page)
+                  .Page((elements, cur) =>
+                  {
+                      if (elements.Count == 0)
+                          return _sender.CreateEmbed().WithDescription(GetText(strs.streams_none)).WithErrorColor();
+
+                      var eb = _sender.CreateEmbed().WithTitle(GetText(strs.streams_follow_title)).WithOkColor();
+                      for (var index = 0; index < elements.Count; index++)
+                      {
+                          var elem = elements[index];
+                          eb.AddField($"**#{index + 1 + (12 * cur)}** {elem.Username.ToLower()}",
+                              $"【{elem.Type}】\n<#{elem.ChannelId}>\n{elem.Message?.TrimTo(50)}",
+                              true);
+                      }
+
+                      return eb;
+                  })
+                  .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        public async Task StreamOffline()
+        {
+            var newValue = _service.ToggleStreamOffline(ctx.Guild.Id);
+            if (newValue)
+                await Response().Confirm(strs.stream_off_enabled).SendAsync();
+            else
+                await Response().Confirm(strs.stream_off_disabled).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        public async Task StreamOnlineDelete()
+        {
+            var newValue = _service.ToggleStreamOnlineDelete(ctx.Guild.Id);
+            if (newValue)
+                await Response().Confirm(strs.stream_online_delete_enabled).SendAsync();
+            else
+                await Response().Confirm(strs.stream_online_delete_disabled).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        public async Task StreamMessage(int index, [Leftover] string message)
+        {
+            if (--index < 0)
+                return;
+
+            var canMentionEveryone = (ctx.User as IGuildUser)?.GuildPermissions.MentionEveryone ?? true;
+            if (!canMentionEveryone)
+                message = message?.SanitizeAllMentions();
+
+            if (!_service.SetStreamMessage(ctx.Guild.Id, index, message, out var fs))
+            {
+                await Response().Confirm(strs.stream_not_following).SendAsync();
+                return;
+            }
+
+            if (string.IsNullOrWhiteSpace(message))
+                await Response().Confirm(strs.stream_message_reset(Format.Bold(fs.Username))).SendAsync();
+            else
+                await Response().Confirm(strs.stream_message_set(Format.Bold(fs.Username))).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageMessages)]
+        public async Task StreamMessageAll([Leftover] string message)
+        {
+            var canMentionEveryone = (ctx.User as IGuildUser)?.GuildPermissions.MentionEveryone ?? true;
+            if (!canMentionEveryone)
+                message = message?.SanitizeAllMentions();
+            
+            var count = _service.SetStreamMessageForAll(ctx.Guild.Id, message);
+
+            if (count == 0)
+            {
+                await Response().Confirm(strs.stream_not_following_any).SendAsync();
+                return;
+            }
+
+            await Response().Confirm(strs.stream_message_set_all(count)).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task StreamCheck(string url)
+        {
+            try
+            {
+                var data = await _service.GetStreamDataAsync(url);
+                if (data is null)
+                {
+                    await Response().Error(strs.no_channel_found).SendAsync();
+                    return;
+                }
+
+                if (data.IsLive)
+                {
+                    await Response()
+                          .Confirm(strs.streamer_online(Format.Bold(data.Name),
+                              Format.Bold(data.Viewers.ToString())))
+                          .SendAsync();
+                }
+                else
+                    await Response().Confirm(strs.streamer_offline(data.Name)).SendAsync();
+            }
+            catch
+            {
+                await Response().Error(strs.no_channel_found).SendAsync();
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs
new file mode 100644
index 0000000..2ad4546
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/StreamNotification/StreamNotificationService.cs
@@ -0,0 +1,671 @@
+#nullable disable
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Common.ModuleBehaviors;
+using EllieBot.Db.Models;
+using EllieBot.Modules.Searches.Common;
+using EllieBot.Modules.Searches.Common.StreamNotifications;
+
+namespace EllieBot.Modules.Searches.Services;
+
+public sealed class StreamNotificationService : INService, IReadyExecutor
+{
+    private readonly DbService _db;
+    private readonly IBotStrings _strings;
+    private readonly Random _rng = new EllieRandom();
+    private readonly DiscordSocketClient _client;
+    private readonly NotifChecker _streamTracker;
+
+    private readonly object _shardLock = new();
+
+    private readonly Dictionary<StreamDataKey, HashSet<ulong>> _trackCounter = new();
+
+    private readonly Dictionary<StreamDataKey, Dictionary<ulong, HashSet<FollowedStream>>> _shardTrackedStreams;
+    private readonly ConcurrentHashSet<ulong> _offlineNotificationServers;
+    private readonly ConcurrentHashSet<ulong> _deleteOnOfflineServers;
+
+    private readonly IPubSub _pubSub;
+    private readonly IMessageSenderService _sender;
+    private readonly SearchesConfigService _config;
+    private readonly IReplacementService _repSvc;
+
+    public TypedKey<List<StreamData>> StreamsOnlineKey { get; }
+    public TypedKey<List<StreamData>> StreamsOfflineKey { get; }
+
+    private readonly TypedKey<FollowStreamPubData> _streamFollowKey;
+    private readonly TypedKey<FollowStreamPubData> _streamUnfollowKey;
+
+    public event Func<
+        FollowedStream.FType,
+        string,
+        IReadOnlyCollection<(ulong, ulong)>,
+        Task> OnlineMessagesSent = static delegate { return Task.CompletedTask; };
+
+    public StreamNotificationService(
+        DbService db,
+        DiscordSocketClient client,
+        IBotStrings strings,
+        IBotCredsProvider creds,
+        IHttpClientFactory httpFactory,
+        IBot bot,
+        IPubSub pubSub,
+        IMessageSenderService sender,
+        SearchesConfigService config,
+        IReplacementService repSvc)
+    {
+        _db = db;
+        _client = client;
+        _strings = strings;
+        _pubSub = pubSub;
+        _sender = sender;
+        _config = config;
+        _repSvc = repSvc;
+
+        _streamTracker = new(httpFactory, creds);
+
+        StreamsOnlineKey = new("streams.online");
+        StreamsOfflineKey = new("streams.offline");
+
+        _streamFollowKey = new("stream.follow");
+        _streamUnfollowKey = new("stream.unfollow");
+
+        using (var uow = db.GetDbContext())
+        {
+            var ids = client.GetGuildIds();
+            var guildConfigs = uow.Set<GuildConfig>()
+                                  .AsQueryable()
+                                  .Include(x => x.FollowedStreams)
+                                  .Where(x => ids.Contains(x.GuildId))
+                                  .ToList();
+
+            _offlineNotificationServers = new(guildConfigs
+                                              .Where(gc => gc.NotifyStreamOffline)
+                                              .Select(x => x.GuildId)
+                                              .ToList());
+
+            _deleteOnOfflineServers = new(guildConfigs
+                                          .Where(gc => gc.DeleteStreamOnlineMessage)
+                                          .Select(x => x.GuildId)
+                                          .ToList());
+
+            var followedStreams = guildConfigs.SelectMany(x => x.FollowedStreams).ToList();
+
+            _shardTrackedStreams = followedStreams.GroupBy(x => new
+                                                  {
+                                                      x.Type,
+                                                      Name = x.Username.ToLower()
+                                                  })
+                                                  .ToList()
+                                                  .ToDictionary(
+                                                      x => new StreamDataKey(x.Key.Type, x.Key.Name.ToLower()),
+                                                      x => x.GroupBy(y => y.GuildId)
+                                                            .ToDictionary(y => y.Key,
+                                                                y => y.AsEnumerable().ToHashSet()));
+
+            // shard 0 will keep track of when there are no more guilds which track a stream
+            if (client.ShardId == 0)
+            {
+                var allFollowedStreams = uow.Set<FollowedStream>().AsQueryable().ToList();
+
+                foreach (var fs in allFollowedStreams)
+                    _streamTracker.AddLastData(fs.CreateKey(), null, false);
+
+                _trackCounter = allFollowedStreams.GroupBy(x => new
+                                                  {
+                                                      x.Type,
+                                                      Name = x.Username.ToLower()
+                                                  })
+                                                  .ToDictionary(x => new StreamDataKey(x.Key.Type, x.Key.Name),
+                                                      x => x.Select(fs => fs.GuildId).ToHashSet());
+            }
+        }
+
+        _pubSub.Sub(StreamsOfflineKey, HandleStreamsOffline);
+        _pubSub.Sub(StreamsOnlineKey, HandleStreamsOnline);
+
+        if (client.ShardId == 0)
+        {
+            // only shard 0 will run the tracker,
+            // and then publish updates with redis to other shards 
+            _streamTracker.OnStreamsOffline += OnStreamsOffline;
+            _streamTracker.OnStreamsOnline += OnStreamsOnline;
+            _ = _streamTracker.RunAsync();
+
+            _pubSub.Sub(_streamFollowKey, HandleFollowStream);
+            _pubSub.Sub(_streamUnfollowKey, HandleUnfollowStream);
+        }
+
+        bot.JoinedGuild += ClientOnJoinedGuild;
+        client.LeftGuild += ClientOnLeftGuild;
+    }
+
+    public async Task OnReadyAsync()
+    {
+        if (_client.ShardId != 0)
+            return;
+
+        using var timer = new PeriodicTimer(TimeSpan.FromMinutes(30));
+        while (await timer.WaitForNextTickAsync())
+        {
+            try
+            {
+                var errorLimit = TimeSpan.FromHours(12);
+                var failingStreams = _streamTracker.GetFailingStreams(errorLimit, true).ToList();
+
+                if (!failingStreams.Any())
+                    continue;
+
+                var deleteGroups = failingStreams.GroupBy(x => x.Type)
+                                                 .ToDictionary(x => x.Key, x => x.Select(y => y.Name).ToList());
+
+                await using var uow = _db.GetDbContext();
+                foreach (var kvp in deleteGroups)
+                {
+                    Log.Information(
+                        "Deleting {StreamCount} {Platform} streams because they've been erroring for more than {ErrorLimit}: {RemovedList}",
+                        kvp.Value.Count,
+                        kvp.Key,
+                        errorLimit,
+                        string.Join(", ", kvp.Value));
+
+                    var toDelete = uow.Set<FollowedStream>()
+                                      .AsQueryable()
+                                      .Where(x => x.Type == kvp.Key && kvp.Value.Contains(x.Username))
+                                      .ToList();
+
+                    uow.RemoveRange(toDelete);
+                    await uow.SaveChangesAsync();
+
+                    foreach (var loginToDelete in kvp.Value)
+                        _streamTracker.UntrackStreamByKey(new(kvp.Key, loginToDelete));
+                }
+            }
+            catch (Exception ex)
+            {
+                Log.Error(ex, "Error cleaning up FollowedStreams");
+            }
+        }
+    }
+
+    /// <summary>
+    ///     Handles follow stream pubs to keep the counter up to date.
+    ///     When counter reaches 0, stream is removed from tracking because
+    ///     that means no guilds are subscribed to that stream anymore
+    /// </summary>
+    private ValueTask HandleFollowStream(FollowStreamPubData info)
+    {
+        _streamTracker.AddLastData(info.Key, null, false);
+        lock (_shardLock)
+        {
+            var key = info.Key;
+            if (_trackCounter.ContainsKey(key))
+                _trackCounter[key].Add(info.GuildId);
+            else
+            {
+                _trackCounter[key] = [info.GuildId];
+            }
+        }
+
+        return default;
+    }
+
+    /// <summary>
+    ///     Handles unfollow pubs to keep the counter up to date.
+    ///     When counter reaches 0, stream is removed from tracking because
+    ///     that means no guilds are subscribed to that stream anymore
+    /// </summary>
+    private ValueTask HandleUnfollowStream(FollowStreamPubData info)
+    {
+        lock (_shardLock)
+        {
+            var key = info.Key;
+            if (!_trackCounter.TryGetValue(key, out var set))
+            {
+                // it should've been removed already?
+                _streamTracker.UntrackStreamByKey(in key);
+                return default;
+            }
+
+            set.Remove(info.GuildId);
+            if (set.Count != 0)
+                return default;
+
+            _trackCounter.Remove(key);
+            // if no other guilds are following this stream
+            // untrack the stream
+            _streamTracker.UntrackStreamByKey(in key);
+        }
+
+        return default;
+    }
+
+    private async ValueTask HandleStreamsOffline(List<StreamData> offlineStreams)
+    {
+        foreach (var stream in offlineStreams)
+        {
+            var key = stream.CreateKey();
+            if (_shardTrackedStreams.TryGetValue(key, out var fss))
+            {
+                await fss
+                      // send offline stream notifications only to guilds which enable it with .stoff
+                      .SelectMany(x => x.Value)
+                      .Where(x => _offlineNotificationServers.Contains(x.GuildId))
+                      .Select(fs =>
+                      {
+                          var ch = _client.GetGuild(fs.GuildId)
+                                          ?.GetTextChannel(fs.ChannelId);
+
+                          if (ch is null)
+                              return Task.CompletedTask;
+
+                          return _sender.Response(ch).Embed(GetEmbed(fs.GuildId, stream)).SendAsync();
+                      })
+                      .WhenAll();
+            }
+        }
+    }
+
+
+    private async ValueTask HandleStreamsOnline(List<StreamData> onlineStreams)
+    {
+        foreach (var stream in onlineStreams)
+        {
+            var key = stream.CreateKey();
+            if (_shardTrackedStreams.TryGetValue(key, out var fss))
+            {
+                var messages = await fss.SelectMany(x => x.Value)
+                                        .Select(async fs =>
+                                        {
+                                            var textChannel = _client.GetGuild(fs.GuildId)
+                                                                     ?.GetTextChannel(fs.ChannelId);
+
+                                            if (textChannel is null)
+                                                return default;
+
+                                            var repCtx = new ReplacementContext(guild: textChannel.Guild,
+                                                    client: _client)
+                                                .WithOverride("%platform%", () => fs.Type.ToString());
+
+
+                                            var message = string.IsNullOrWhiteSpace(fs.Message)
+                                                ? ""
+                                                : await _repSvc.ReplaceAsync(fs.Message, repCtx);
+
+                                            var msg = await _sender.Response(textChannel)
+                                                                   .Embed(GetEmbed(fs.GuildId, stream, false))
+                                                                   .Text(message)
+                                                                   .Sanitize(false)
+                                                                   .SendAsync();
+
+                                            // only cache the ids of channel/message pairs 
+                                            if (_deleteOnOfflineServers.Contains(fs.GuildId))
+                                                return (textChannel.Id, msg.Id);
+                                            else
+                                                return default;
+                                        })
+                                        .WhenAll();
+
+
+                // push online stream messages to redis
+                // when streams go offline, any server which
+                // has the online stream message deletion feature
+                // enabled will have the online messages deleted
+                try
+                {
+                    var pairs = messages
+                                .Where(x => x != default)
+                                .Select(x => (x.Item1, x.Item2))
+                                .ToList();
+
+                    if (pairs.Count > 0)
+                        await OnlineMessagesSent(key.Type, key.Name, pairs);
+                }
+                catch
+                {
+                }
+            }
+        }
+    }
+
+    private Task OnStreamsOnline(List<StreamData> data)
+        => _pubSub.Pub(StreamsOnlineKey, data);
+
+    private Task OnStreamsOffline(List<StreamData> data)
+        => _pubSub.Pub(StreamsOfflineKey, data);
+
+    private Task ClientOnJoinedGuild(GuildConfig guildConfig)
+    {
+        using (var uow = _db.GetDbContext())
+        {
+            var gc = uow.Set<GuildConfig>()
+                        .AsQueryable()
+                        .Include(x => x.FollowedStreams)
+                        .FirstOrDefault(x => x.GuildId == guildConfig.GuildId);
+
+            if (gc is null)
+                return Task.CompletedTask;
+
+            if (gc.NotifyStreamOffline)
+                _offlineNotificationServers.Add(gc.GuildId);
+
+            foreach (var followedStream in gc.FollowedStreams)
+            {
+                var key = followedStream.CreateKey();
+                var streams = GetLocalGuildStreams(key, gc.GuildId);
+                streams.Add(followedStream);
+                PublishFollowStream(followedStream);
+            }
+        }
+
+        return Task.CompletedTask;
+    }
+
+    private Task ClientOnLeftGuild(SocketGuild guild)
+    {
+        using (var uow = _db.GetDbContext())
+        {
+            var gc = uow.GuildConfigsForId(guild.Id, set => set.Include(x => x.FollowedStreams));
+
+            _offlineNotificationServers.TryRemove(gc.GuildId);
+
+            foreach (var followedStream in gc.FollowedStreams)
+            {
+                var streams = GetLocalGuildStreams(followedStream.CreateKey(), guild.Id);
+                streams.Remove(followedStream);
+
+                PublishUnfollowStream(followedStream);
+            }
+        }
+
+        return Task.CompletedTask;
+    }
+
+    public async Task<int> ClearAllStreams(ulong guildId)
+    {
+        await using var uow = _db.GetDbContext();
+        var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FollowedStreams));
+        uow.RemoveRange(gc.FollowedStreams);
+
+        foreach (var s in gc.FollowedStreams)
+            await PublishUnfollowStream(s);
+
+        uow.SaveChanges();
+
+        return gc.FollowedStreams.Count;
+    }
+
+    public async Task<FollowedStream> UnfollowStreamAsync(ulong guildId, int index)
+    {
+        FollowedStream fs;
+        await using (var uow = _db.GetDbContext())
+        {
+            var fss = uow.Set<FollowedStream>()
+                         .AsQueryable()
+                         .Where(x => x.GuildId == guildId)
+                         .OrderBy(x => x.Id)
+                         .ToList();
+
+            // out of range
+            if (fss.Count <= index)
+                return null;
+
+            fs = fss[index];
+            uow.Remove(fs);
+
+            await uow.SaveChangesAsync();
+
+            // remove from local cache
+            lock (_shardLock)
+            {
+                var key = fs.CreateKey();
+                var streams = GetLocalGuildStreams(key, guildId);
+                streams.Remove(fs);
+            }
+        }
+
+        await PublishUnfollowStream(fs);
+
+        return fs;
+    }
+
+    private void PublishFollowStream(FollowedStream fs)
+        => _pubSub.Pub(_streamFollowKey,
+            new()
+            {
+                Key = fs.CreateKey(),
+                GuildId = fs.GuildId
+            });
+
+    private Task PublishUnfollowStream(FollowedStream fs)
+        => _pubSub.Pub(_streamUnfollowKey,
+            new()
+            {
+                Key = fs.CreateKey(),
+                GuildId = fs.GuildId
+            });
+
+    public async Task<StreamData> FollowStream(ulong guildId, ulong channelId, string url)
+    {
+        // this will 
+        var data = await _streamTracker.GetStreamDataByUrlAsync(url);
+
+        if (data is null)
+            return null;
+
+        FollowedStream fs;
+        await using (var uow = _db.GetDbContext())
+        {
+            var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.FollowedStreams));
+
+            // add it to the database
+            fs = new()
+            {
+                Type = data.StreamType,
+                Username = data.UniqueName,
+                ChannelId = channelId,
+                GuildId = guildId
+            };
+
+            var config = _config.Data;
+            if (config.FollowedStreams.MaxCount is not -1
+                && gc.FollowedStreams.Count >= config.FollowedStreams.MaxCount)
+                return null;
+
+            gc.FollowedStreams.Add(fs);
+            await uow.SaveChangesAsync();
+
+            // add it to the local cache of tracked streams
+            // this way this shard will know it needs to post a message to discord
+            // when shard 0 publishes stream status changes for this stream 
+            lock (_shardLock)
+            {
+                var key = data.CreateKey();
+                var streams = GetLocalGuildStreams(key, guildId);
+                streams.Add(fs);
+            }
+        }
+
+        PublishFollowStream(fs);
+
+        return data;
+    }
+
+    public EmbedBuilder GetEmbed(ulong guildId, StreamData status, bool showViewers = true)
+    {
+        var embed = _sender.CreateEmbed()
+                           .WithTitle(status.Name)
+                           .WithUrl(status.StreamUrl)
+                           .WithDescription(status.StreamUrl)
+                           .AddField(GetText(guildId, strs.status), status.IsLive ? "🟢 Online" : "🔴 Offline", true);
+
+        if (showViewers)
+        {
+            embed.AddField(GetText(guildId, strs.viewers),
+                status.Viewers == 0 && !status.IsLive
+                    ? "-"
+                    : status.Viewers,
+                true);
+        }
+
+        if (status.IsLive)
+            embed = embed.WithOkColor();
+        else
+            embed = embed.WithErrorColor();
+
+        if (!string.IsNullOrWhiteSpace(status.Title))
+            embed.WithAuthor(status.Title);
+
+        if (!string.IsNullOrWhiteSpace(status.Game))
+            embed.AddField(GetText(guildId, strs.streaming), status.Game, true);
+
+        if (!string.IsNullOrWhiteSpace(status.AvatarUrl))
+            embed.WithThumbnailUrl(status.AvatarUrl);
+
+        if (!string.IsNullOrWhiteSpace(status.Preview))
+            embed.WithImageUrl(status.Preview + "?dv=" + _rng.Next());
+
+        return embed;
+    }
+
+    private string GetText(ulong guildId, LocStr str)
+        => _strings.GetText(str, guildId);
+
+    public bool ToggleStreamOffline(ulong guildId)
+    {
+        bool newValue;
+        using var uow = _db.GetDbContext();
+        var gc = uow.GuildConfigsForId(guildId, set => set);
+        newValue = gc.NotifyStreamOffline = !gc.NotifyStreamOffline;
+        uow.SaveChanges();
+
+        if (newValue)
+            _offlineNotificationServers.Add(guildId);
+        else
+            _offlineNotificationServers.TryRemove(guildId);
+
+        return newValue;
+    }
+
+    public bool ToggleStreamOnlineDelete(ulong guildId)
+    {
+        using var uow = _db.GetDbContext();
+        var gc = uow.GuildConfigsForId(guildId, set => set);
+        var newValue = gc.DeleteStreamOnlineMessage = !gc.DeleteStreamOnlineMessage;
+        uow.SaveChanges();
+
+        if (newValue)
+            _deleteOnOfflineServers.Add(guildId);
+        else
+            _deleteOnOfflineServers.TryRemove(guildId);
+
+        return newValue;
+    }
+
+    public Task<StreamData> GetStreamDataAsync(string url)
+        => _streamTracker.GetStreamDataByUrlAsync(url);
+
+    private HashSet<FollowedStream> GetLocalGuildStreams(in StreamDataKey key, ulong guildId)
+    {
+        if (_shardTrackedStreams.TryGetValue(key, out var map))
+        {
+            if (map.TryGetValue(guildId, out var set))
+                return set;
+            return map[guildId] = [];
+        }
+
+        _shardTrackedStreams[key] = new()
+        {
+            { guildId, [] }
+        };
+        return _shardTrackedStreams[key][guildId];
+    }
+
+    public bool SetStreamMessage(
+        ulong guildId,
+        int index,
+        string message,
+        out FollowedStream fs)
+    {
+        using var uow = _db.GetDbContext();
+        var fss = uow.Set<FollowedStream>().AsQueryable().Where(x => x.GuildId == guildId).OrderBy(x => x.Id).ToList();
+
+        if (fss.Count <= index)
+        {
+            fs = null;
+            return false;
+        }
+
+        fs = fss[index];
+        fs.Message = message;
+        lock (_shardLock)
+        {
+            var streams = GetLocalGuildStreams(fs.CreateKey(), guildId);
+
+            // message doesn't participate in equality checking
+            // removing and adding = update
+            streams.Remove(fs);
+            streams.Add(fs);
+        }
+
+        uow.SaveChanges();
+
+        return true;
+    }
+
+    public int SetStreamMessageForAll(ulong guildId, string message)
+    {
+        using var uow = _db.GetDbContext();
+
+        var all = uow.Set<FollowedStream>()
+                     .Where(x => x.GuildId == guildId)
+                     .ToList();
+
+        if (all.Count == 0)
+            return 0;
+
+        all.ForEach(x => x.Message = message);
+
+        uow.SaveChanges();
+
+        lock (_shardLock)
+        {
+            foreach (var fs in all)
+            {
+                var streams = GetLocalGuildStreams(fs.CreateKey(), guildId);
+
+                // message doesn't participate in equality checking
+                // removing and adding = update
+                streams.Remove(fs);
+                streams.Add(fs);
+            }
+        }
+
+        return all.Count;
+    }
+
+    public sealed class FollowStreamPubData
+    {
+        public StreamDataKey Key { get; init; }
+        public ulong GuildId { get; init; }
+    }
+
+    public async Task<List<FollowedStream>> GetAllStreamsAsync(SocketGuild guild)
+    {
+        var allStreams = new List<FollowedStream>();
+        await using var uow = _db.GetDbContext();
+        var all = uow.GuildConfigsForId(guild.Id, set => set.Include(gc => gc.FollowedStreams))
+                     .FollowedStreams
+                     .OrderBy(x => x.Id)
+                     .ToList();
+
+        for (var index = all.Count - 1; index >= 0; index--)
+        {
+            var fs = all[index];
+            if (guild.GetTextChannel(fs.ChannelId) is null)
+                await UnfollowStreamAsync(fs.GuildId, index);
+            else
+                allStreams.Insert(0, fs);
+        }
+
+        return allStreams;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs b/src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs
new file mode 100644
index 0000000..8ea24d6
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/StreamNotification/StreamOnlineMessageDeleterService.cs
@@ -0,0 +1,99 @@
+#nullable disable
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using EllieBot.Common.ModuleBehaviors;
+using EllieBot.Db.Models;
+using EllieBot.Modules.Searches.Common;
+
+namespace EllieBot.Modules.Searches.Services;
+
+public sealed class StreamOnlineMessageDeleterService : INService, IReadyExecutor
+{
+    private readonly StreamNotificationService _notifService;
+    private readonly DbService _db;
+    private readonly DiscordSocketClient _client;
+    private readonly IPubSub _pubSub;
+
+    public StreamOnlineMessageDeleterService(
+        StreamNotificationService notifService,
+        DbService db,
+        IPubSub pubSub,
+        DiscordSocketClient client)
+    {
+        _notifService = notifService;
+        _db = db;
+        _client = client;
+        _pubSub = pubSub;
+    }
+
+    public async Task OnReadyAsync()
+    {
+        _notifService.OnlineMessagesSent += OnOnlineMessagesSent;
+
+        if (_client.ShardId == 0)
+            await _pubSub.Sub(_notifService.StreamsOfflineKey, OnStreamsOffline);
+    }
+
+    private async Task OnOnlineMessagesSent(
+        FollowedStream.FType type,
+        string name,
+        IReadOnlyCollection<(ulong, ulong)> pairs)
+    {
+        await using var ctx = _db.GetDbContext();
+        foreach (var (channelId, messageId) in pairs)
+        {
+            await ctx.GetTable<StreamOnlineMessage>()
+                     .InsertAsync(() => new()
+                     {
+                         Name = name,
+                         Type = type,
+                         MessageId = messageId,
+                         ChannelId = channelId,
+                         DateAdded = DateTime.UtcNow,
+                     });
+        }
+    }
+
+    private async ValueTask OnStreamsOffline(List<StreamData> streamDatas)
+    {
+        if (_client.ShardId != 0)
+            return;
+
+        var pairs = await GetMessagesToDelete(streamDatas);
+
+        foreach (var (channelId, messageId) in pairs)
+        {
+            try
+            {
+                var textChannel = await _client.GetChannelAsync(channelId) as ITextChannel;
+                if (textChannel is null)
+                    continue;
+
+                await textChannel.DeleteMessageAsync(messageId);
+            }
+            catch
+            {
+                continue;
+            }
+        }
+    }
+
+    private async Task<IEnumerable<(ulong, ulong)>> GetMessagesToDelete(List<StreamData> streamDatas)
+    {
+        await using var ctx = _db.GetDbContext();
+
+        var toReturn = new List<(ulong, ulong)>();
+        foreach (var sd in streamDatas)
+        {
+            var key = sd.CreateKey();
+            var toDelete = await ctx.GetTable<StreamOnlineMessage>()
+                                    .Where(x => (x.Type == key.Type && x.Name == key.Name)
+                                                || Sql.DateDiff(Sql.DateParts.Day, x.DateAdded, DateTime.UtcNow) > 1)
+                                    .DeleteWithOutputAsync();
+
+            toReturn.AddRange(toDelete.Select(x => (x.ChannelId, x.MessageId)));
+        }
+
+        return toReturn;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Translate/ITranslateService.cs b/src/EllieBot/Modules/Searches/Translate/ITranslateService.cs
new file mode 100644
index 0000000..6766b6f
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Translate/ITranslateService.cs
@@ -0,0 +1,17 @@
+#nullable disable
+namespace EllieBot.Modules.Searches;
+
+public interface ITranslateService
+{
+    public Task<string> Translate(string source, string target, string text = null);
+    Task<bool> ToggleAtl(ulong guildId, ulong channelId, bool autoDelete);
+    IEnumerable<string> GetLanguages();
+
+    Task<bool?> RegisterUserAsync(
+        ulong userId,
+        ulong channelId,
+        string from,
+        string to);
+
+    Task<bool> UnregisterUser(ulong channelId, ulong userId);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Translate/TranslateService.cs b/src/EllieBot/Modules/Searches/Translate/TranslateService.cs
new file mode 100644
index 0000000..ab1a463
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Translate/TranslateService.cs
@@ -0,0 +1,224 @@
+#nullable disable
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Common.ModuleBehaviors;
+using EllieBot.Db.Models;
+using System.Net;
+
+namespace EllieBot.Modules.Searches;
+
+public sealed class TranslateService : ITranslateService, IExecNoCommand, IReadyExecutor, INService
+{
+    private readonly IGoogleApiService _google;
+    private readonly DbService _db;
+    private readonly IMessageSenderService _sender;
+    private readonly IBot _bot;
+
+    private readonly ConcurrentDictionary<ulong, bool> _atcs = new();
+    private readonly ConcurrentDictionary<ulong, ConcurrentDictionary<ulong, (string From, string To)>> _users = new();
+
+    public TranslateService(
+        IGoogleApiService google,
+        DbService db,
+        IMessageSenderService sender,
+        IBot bot)
+    {
+        _google = google;
+        _db = db;
+        _sender = sender;
+        _bot = bot;
+    }
+
+    public async Task OnReadyAsync()
+    {
+        List<AutoTranslateChannel> cs;
+        await using (var ctx = _db.GetDbContext())
+        {
+            var guilds = _bot.AllGuildConfigs.Select(x => x.GuildId).ToList();
+            cs = await ctx.Set<AutoTranslateChannel>().Include(x => x.Users)
+                          .Where(x => guilds.Contains(x.GuildId))
+                          .ToListAsyncEF();
+        }
+
+        foreach (var c in cs)
+        {
+            _atcs[c.ChannelId] = c.AutoDelete;
+            _users[c.ChannelId] =
+                new(c.Users.ToDictionary(x => x.UserId, x => (x.Source.ToLower(), x.Target.ToLower())));
+        }
+    }
+
+
+    public async Task ExecOnNoCommandAsync(IGuild guild, IUserMessage msg)
+    {
+        if (string.IsNullOrWhiteSpace(msg.Content))
+            return;
+
+        if (msg is { Channel: ITextChannel tch } um)
+        {
+            if (!_atcs.TryGetValue(tch.Id, out var autoDelete))
+                return;
+
+            if (!_users.TryGetValue(tch.Id, out var users) || !users.TryGetValue(um.Author.Id, out var langs))
+                return;
+
+            var output = await _google.Translate(msg.Content, langs.From, langs.To);
+
+            if (string.IsNullOrWhiteSpace(output)
+                || msg.Content.Equals(output, StringComparison.InvariantCultureIgnoreCase))
+                return;
+
+            var embed = _sender.CreateEmbed().WithOkColor();
+
+            if (autoDelete)
+            {
+                embed.WithAuthor(um.Author.ToString(), um.Author.GetAvatarUrl())
+                     .AddField(langs.From, um.Content)
+                     .AddField(langs.To, output);
+
+                await _sender.Response(tch).Embed(embed).SendAsync();
+
+                try
+                {
+                    await um.DeleteAsync();
+                }
+                catch (HttpException ex) when (ex.HttpCode == HttpStatusCode.Forbidden)
+                {
+                    _atcs.TryUpdate(tch.Id, false, true);
+                }
+
+                return;
+            }
+
+            await um.ReplyAsync(embed: embed.AddField(langs.To, output).Build(), allowedMentions: AllowedMentions.None);
+        }
+    }
+
+    public async Task<string> Translate(string source, string target, string text = null)
+    {
+        if (string.IsNullOrWhiteSpace(text))
+            throw new ArgumentException("Text is empty or null", nameof(text));
+
+        var res = await _google.Translate(text, source.ToLowerInvariant(), target.ToLowerInvariant());
+        return res.SanitizeMentions(true);
+    }
+
+    public async Task<bool> ToggleAtl(ulong guildId, ulong channelId, bool autoDelete)
+    {
+        await using var ctx = _db.GetDbContext();
+
+        var old = await ctx.Set<AutoTranslateChannel>().ToLinqToDBTable()
+                           .FirstOrDefaultAsyncLinqToDB(x => x.ChannelId == channelId);
+
+        if (old is null)
+        {
+            ctx.Set<AutoTranslateChannel>().Add(new()
+            {
+                GuildId = guildId,
+                ChannelId = channelId,
+                AutoDelete = autoDelete
+            });
+
+            await ctx.SaveChangesAsync();
+
+            _atcs[channelId] = autoDelete;
+            _users[channelId] = new();
+
+            return true;
+        }
+
+        // if autodelete value is different, update the autodelete value
+        // instead of disabling
+        if (old.AutoDelete != autoDelete)
+        {
+            old.AutoDelete = autoDelete;
+            await ctx.SaveChangesAsync();
+            _atcs[channelId] = autoDelete;
+            return true;
+        }
+
+        await ctx.Set<AutoTranslateChannel>().ToLinqToDBTable().DeleteAsync(x => x.ChannelId == channelId);
+
+        await ctx.SaveChangesAsync();
+        _atcs.TryRemove(channelId, out _);
+        _users.TryRemove(channelId, out _);
+
+        return false;
+    }
+
+
+    private void UpdateUser(
+        ulong channelId,
+        ulong userId,
+        string from,
+        string to)
+    {
+        var dict = _users.GetOrAdd(channelId, new ConcurrentDictionary<ulong, (string, string)>());
+        dict[userId] = (from, to);
+    }
+
+    public async Task<bool?> RegisterUserAsync(
+        ulong userId,
+        ulong channelId,
+        string from,
+        string to)
+    {
+        if (!_google.Languages.ContainsKey(from) || !_google.Languages.ContainsKey(to))
+            return null;
+
+        await using var ctx = _db.GetDbContext();
+        var ch = await ctx.Set<AutoTranslateChannel>().GetByChannelId(channelId);
+
+        if (ch is null)
+            return null;
+
+        var user = ch.Users.FirstOrDefault(x => x.UserId == userId);
+
+        if (user is null)
+        {
+            ch.Users.Add(user = new()
+            {
+                Source = from,
+                Target = to,
+                UserId = userId
+            });
+
+            await ctx.SaveChangesAsync();
+
+            UpdateUser(channelId, userId, from, to);
+
+            return true;
+        }
+
+        // if it's different from old settings, update
+        if (user.Source != from || user.Target != to)
+        {
+            user.Source = from;
+            user.Target = to;
+
+            await ctx.SaveChangesAsync();
+
+            UpdateUser(channelId, userId, from, to);
+
+            return true;
+        }
+
+        return await UnregisterUser(channelId, userId);
+    }
+
+    public async Task<bool> UnregisterUser(ulong channelId, ulong userId)
+    {
+        await using var ctx = _db.GetDbContext();
+        var rows = await ctx.Set<AutoTranslateUser>().ToLinqToDBTable()
+                            .DeleteAsync(x => x.UserId == userId && x.Channel.ChannelId == channelId);
+
+        if (_users.TryGetValue(channelId, out var inner))
+            inner.TryRemove(userId, out _);
+        
+        return rows > 0;
+    }
+
+    public IEnumerable<string> GetLanguages()
+        => _google.Languages.GroupBy(x => x.Value).Select(x => $"{x.AsEnumerable().Select(y => y.Key).Join(", ")}");
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs b/src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs
new file mode 100644
index 0000000..348ca61
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/Translate/TranslatorCommands.cs
@@ -0,0 +1,95 @@
+#nullable disable
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches
+{
+    [Group]
+    public partial class TranslateCommands : EllieModule<ITranslateService>
+    {
+        public enum AutoDeleteAutoTranslate
+        {
+            Del,
+            Nodel
+        }
+
+        [Cmd]
+        public async Task Translate(string fromLang, string toLang, [Leftover] string text = null)
+        {
+            try
+            {
+                await ctx.Channel.TriggerTypingAsync();
+                var translation = await _service.Translate(fromLang, toLang, text);
+
+                var embed = _sender.CreateEmbed().WithOkColor().AddField(fromLang, text).AddField(toLang, translation);
+
+                await Response().Embed(embed).SendAsync();
+            }
+            catch
+            {
+                await Response().Error(strs.bad_input_format).SendAsync();
+            }
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.Administrator)]
+        [BotPerm(ChannelPerm.ManageMessages)]
+        [OwnerOnly]
+        public async Task AutoTranslate(AutoDeleteAutoTranslate autoDelete = AutoDeleteAutoTranslate.Nodel)
+        {
+            var toggle =
+                await _service.ToggleAtl(ctx.Guild.Id, ctx.Channel.Id, autoDelete == AutoDeleteAutoTranslate.Del);
+            if (toggle)
+                await Response().Confirm(strs.atl_started).SendAsync();
+            else
+                await Response().Confirm(strs.atl_stopped).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task AutoTransLang()
+        {
+            if (await _service.UnregisterUser(ctx.Channel.Id, ctx.User.Id))
+                await Response().Confirm(strs.atl_removed).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task AutoTransLang(string fromLang, string toLang)
+        {
+            var succ = await _service.RegisterUserAsync(ctx.User.Id, ctx.Channel.Id, fromLang.ToLower(), toLang.ToLower());
+
+            if (succ is null)
+            {
+                await Response().Error(strs.atl_not_enabled).SendAsync();
+                return;
+            }
+
+            if (succ is false)
+            {
+                await Response().Error(strs.invalid_lang).SendAsync();
+                return;
+            }
+
+            await Response().Confirm(strs.atl_set(fromLang, toLang)).SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task Translangs()
+        {
+            var langs = _service.GetLanguages().ToList();
+            
+            var eb = _sender.CreateEmbed()
+                        .WithTitle(GetText(strs.supported_languages))
+                        .WithOkColor();
+
+            foreach (var chunk in langs.Chunk(15))
+            {
+                eb.AddField("󠀁", chunk.Join("\n"), inline: true);
+            }
+
+            await Response().Embed(eb).SendAsync();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/XkcdCommands.cs b/src/EllieBot/Modules/Searches/XkcdCommands.cs
new file mode 100644
index 0000000..d913a87
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/XkcdCommands.cs
@@ -0,0 +1,97 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches
+{
+    [Group]
+    public partial class XkcdCommands : EllieModule
+    {
+        private const string XKCD_URL = "https://xkcd.com";
+        private readonly IHttpClientFactory _httpFactory;
+
+        public XkcdCommands(IHttpClientFactory factory)
+            => _httpFactory = factory;
+
+        [Cmd]
+        [Priority(0)]
+        public async Task Xkcd(string arg = null)
+        {
+            if (arg?.ToLowerInvariant().Trim() == "latest")
+            {
+                try
+                {
+                    using var http = _httpFactory.CreateClient();
+                    var res = await http.GetStringAsync($"{XKCD_URL}/info.0.json");
+                    var comic = JsonConvert.DeserializeObject<XkcdComic>(res);
+                    var embed = _sender.CreateEmbed()
+                                   .WithOkColor()
+                                   .WithImageUrl(comic.ImageLink)
+                                   .WithAuthor(comic.Title, "https://xkcd.com/s/919f27.ico", $"{XKCD_URL}/{comic.Num}")
+                                   .AddField(GetText(strs.comic_number), comic.Num.ToString(), true)
+                                   .AddField(GetText(strs.date), $"{comic.Month}/{comic.Year}", true);
+                    var sent = await Response().Embed(embed).SendAsync();
+
+                    await Task.Delay(10000);
+
+                    await sent.ModifyAsync(m => m.Embed = embed.AddField("Alt", comic.Alt).Build());
+                }
+                catch (HttpRequestException)
+                {
+                    await Response().Error(strs.comic_not_found).SendAsync();
+                }
+
+                return;
+            }
+
+            await Xkcd(new EllieRandom().Next(1, 1750));
+        }
+
+        [Cmd]
+        [Priority(1)]
+        public async Task Xkcd(int num)
+        {
+            if (num < 1)
+                return;
+            try
+            {
+                using var http = _httpFactory.CreateClient();
+                var res = await http.GetStringAsync($"{XKCD_URL}/{num}/info.0.json");
+
+                var comic = JsonConvert.DeserializeObject<XkcdComic>(res);
+                var embed = _sender.CreateEmbed()
+                               .WithOkColor()
+                               .WithImageUrl(comic.ImageLink)
+                               .WithAuthor(comic.Title, "https://xkcd.com/s/919f27.ico", $"{XKCD_URL}/{num}")
+                               .AddField(GetText(strs.comic_number), comic.Num.ToString(), true)
+                               .AddField(GetText(strs.date), $"{comic.Month}/{comic.Year}", true);
+
+                var sent = await Response().Embed(embed).SendAsync();
+
+                await Task.Delay(10000);
+
+                await sent.ModifyAsync(m => m.Embed = embed.AddField("Alt", comic.Alt).Build());
+            }
+            catch (HttpRequestException)
+            {
+                await Response().Error(strs.comic_not_found).SendAsync();
+            }
+        }
+    }
+
+    public class XkcdComic
+    {
+        public int Num { get; set; }
+        public string Month { get; set; }
+        public string Year { get; set; }
+
+        [JsonProperty("safe_title")]
+        public string Title { get; set; }
+
+        [JsonProperty("img")]
+        public string ImageLink { get; set; }
+
+        public string Alt { get; set; }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs b/src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs
new file mode 100644
index 0000000..9b20fee
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/YoutubeTrack/YtTrackService.cs
@@ -0,0 +1,134 @@
+#nullable disable
+
+// public class YtTrackService : INService
+// {
+// private readonly IGoogleApiService _google;
+// private readonly IHttpClientFactory httpClientFactory;
+// private readonly DiscordSocketClient _client;
+// private readonly DbService _db;
+// private readonly ConcurrentDictionary<string, ConcurrentDictionary<ulong, List<YtFollowedChannel>>> followedChannels;
+// private readonly ConcurrentDictionary<string, DateTime> _latestPublishes = new ConcurrentDictionary<string, DateTime>();
+//
+// public YtTrackService(IGoogleApiService google, IHttpClientFactory httpClientFactory, DiscordSocketClient client,
+//     DbService db)
+// {
+//     this._google = google;
+//     this.httpClientFactory = httpClientFactory;
+//     this._client = client;
+//     this._db = db;
+//
+//     if (_client.ShardId == 0)
+//     {
+//         _ = CheckLoop();
+//     }
+// }
+//
+// public async Task CheckLoop()
+// {
+//     while (true)
+//     {
+//         await Task.Delay(10000);
+//         using (var http = httpClientFactory.CreateClient())
+//         {
+//             await followedChannels.Select(kvp => CheckChannel(kvp.Key, kvp.Value.SelectMany(x => x.Value).ToList())).WhenAll();
+//         }
+//     }
+// }
+//
+// /// <summary>
+// /// Checks the specified youtube channel, and sends a message to all provided
+// /// </summary>
+// /// <param name="youtubeChannelId">Id of the youtube channel</param>
+// /// <param name="followedChannels">Where to post updates if there is a new update</param>
+// private async Task CheckChannel(string youtubeChannelId, List<YtFollowedChannel> followedChannels)
+// {
+//     var latestVid = (await _google.GetLatestChannelVideosAsync(youtubeChannelId, 1))
+//         .FirstOrDefault();
+//     if (latestVid is null)
+//     {
+//         return;
+//     }
+//
+//     if (_latestPublishes.TryGetValue(youtubeChannelId, out var latestPub) && latestPub >= latestVid.PublishedAt)
+//     {
+//         return;
+//     }
+//     _latestPublishes[youtubeChannelId] = latestVid.PublishedAt;
+//
+//     foreach (var chObj in followedChannels)
+//     {
+//         var gCh = _client.GetChannel(chObj.ChannelId);
+//         if (gCh is ITextChannel ch)
+//         {
+//             var msg = latestVid.GetVideoUrl();
+//             if (!string.IsNullOrWhiteSpace(chObj.UploadMessage))
+//                 msg = chObj.UploadMessage + Environment.NewLine + msg;
+//
+//             await ch.SendMessageAsync(msg);
+//         }
+//     }
+// }
+//
+// /// <summary>
+// /// Starts posting updates on the specified discord channel when a new video is posted on the specified YouTube channel.
+// /// </summary>
+// /// <param name="guildId">Id of the discord guild</param>
+// /// <param name="channelId">Id of the discord channel</param>
+// /// <param name="ytChannelId">Id of the youtube channel</param>
+// /// <param name="uploadMessage">Message to post when a new video is uploaded, along with video URL</param>
+// /// <returns>Whether adding was successful</returns>
+// public async Task<bool> ToggleChannelFollowAsync(ulong guildId, ulong channelId, string ytChannelId, string uploadMessage)
+// {
+//     // to to see if we can get a video from that channel
+//     var vids = await _google.GetLatestChannelVideosAsync(ytChannelId, 1);
+//     if (vids.Count == 0)
+//         return false;
+//
+//     using(var uow = _db.GetDbContext())
+//     {
+//         var gc = uow.GuildConfigsForId(guildId, set => set.Include(x => x.YtFollowedChannels));
+//
+//         // see if this yt channel was already followed on this discord channel
+//         var oldObj = gc.YtFollowedChannels
+//             .FirstOrDefault(x => x.ChannelId == channelId && x.YtChannelId == ytChannelId);
+//
+//         if(oldObj is not null)
+//         {
+//             return false;
+//         }
+//
+//         // can only add up to 10 tracked channels per server
+//         if (gc.YtFollowedChannels.Count >= 10)
+//         {
+//             return false;
+//         }
+//
+//         var obj = new YtFollowedChannel
+//         {
+//             ChannelId = channelId,
+//             YtChannelId = ytChannelId,
+//             UploadMessage = uploadMessage
+//         };
+//
+//         // add to database
+//         gc.YtFollowedChannels.Add(obj);
+//
+//         // add to the local cache:
+//
+//         // get follows on all guilds
+//         var allGuildFollows = followedChannels.GetOrAdd(ytChannelId, new ConcurrentDictionary<ulong, List<YtFollowedChannel>>());
+//         // add to this guild's follows
+//         allGuildFollows.AddOrUpdate(guildId,
+//             new List<YtFollowedChannel>(),
+//             (key, old) =>
+//             {
+//                 old.Add(obj);
+//                 return old;
+//             });
+//
+//         await uow.SaveChangesAsync();
+//     }
+//
+//     return true;
+// }
+// }
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs b/src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs
new file mode 100644
index 0000000..2439ad4
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/YoutubeTrack/YtUploadCommands.cs
@@ -0,0 +1,54 @@
+#nullable disable
+namespace EllieBot.Modules.Searches;
+
+public partial class Searches
+{
+    // [Group]
+    // public partial class YtTrackCommands : EllieModule<YtTrackService>
+    // {
+    //     ;
+    //     [RequireContext(ContextType.Guild)]
+    //     public async Task YtFollow(string ytChannelId, [Leftover] string uploadMessage = null)
+    //     {
+    //         var succ = await _service.ToggleChannelFollowAsync(ctx.Guild.Id, ctx.Channel.Id, ytChannelId, uploadMessage);
+    //         if(succ)
+    //         {
+    //             await Response().Confirm(strs.yt_follow_added).SendAsync();
+    //         }
+    //         else
+    //         {
+    //             await Response().Confirm(strs.yt_follow_fail).SendAsync();
+    //         }
+    //     }
+    //     
+    //     [EllieCommand, Usage, Description, Aliases]
+    //     [RequireContext(ContextType.Guild)]
+    //     public async Task YtTrackRm(int index)
+    //     {
+    //         //var succ = await _service.ToggleChannelTrackingAsync(ctx.Guild.Id, ctx.Channel.Id, ytChannelId, uploadMessage);
+    //         //if (succ)
+    //         //{
+    //         //    await Response().Confirm(strs.yt_track_added).SendAsync();
+    //         //}
+    //         //else
+    //         //{
+    //         //    await Response().Confirm(strs.yt_track_fail).SendAsync();
+    //         //}
+    //     }
+    //     
+    //     [EllieCommand, Usage, Description, Aliases]
+    //     [RequireContext(ContextType.Guild)]
+    //     public async Task YtTrackList()
+    //     {
+    //         //var succ = await _service.ToggleChannelTrackingAsync(ctx.Guild.Id, ctx.Channel.Id, ytChannelId, uploadMessage);
+    //         //if (succ)
+    //         //{
+    //         //    await Response().Confirm(strs.yt_track_added).SendAsync();
+    //         //}
+    //         //else
+    //         //{
+    //         //    await Response().Confirm(strs.yt_track_fail).SendAsync();
+    //         //}
+    //     }
+    // }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/AtlExtensions.cs b/src/EllieBot/Modules/Searches/_common/AtlExtensions.cs
new file mode 100644
index 0000000..e8ab960
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/AtlExtensions.cs
@@ -0,0 +1,12 @@
+#nullable disable
+using LinqToDB.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Searches;
+
+public static class AtlExtensions
+{
+    public static Task<AutoTranslateChannel> GetByChannelId(this IQueryable<AutoTranslateChannel> set, ulong channelId)
+        => set.Include(x => x.Users).FirstOrDefaultAsyncEF(x => x.ChannelId == channelId);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs b/src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs
new file mode 100644
index 0000000..b34fb36
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/Config/ImgSearchEngine.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.Searches;
+
+public enum ImgSearchEngine
+{
+    Google,
+    Searx,
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs
new file mode 100644
index 0000000..8cb0227
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfig.cs
@@ -0,0 +1,85 @@
+using Cloneable;
+using EllieBot.Common.Yml;
+
+namespace EllieBot.Modules.Searches;
+
+[Cloneable]
+public partial class SearchesConfig : ICloneable<SearchesConfig>
+{
+    [Comment("DO NOT CHANGE")]
+    public int Version { get; set; } = 3;
+
+    [Comment("""
+             Which engine should .search command
+             'google_scrape' - default. Scrapes the webpage for results. May break. Requires no api keys.
+             'google' - official google api. Requires googleApiKey and google.searchId set in creds.yml
+             'searx' - requires at least one searx instance specified in the 'searxInstances' property below
+             """)]
+    public WebSearchEngine WebSearchEngine { get; set; } = WebSearchEngine.Google_Scrape;
+
+    [Comment("""
+             Which engine should .image command use
+             'google'- official google api. googleApiKey and google.imageSearchId set in creds.yml
+             'searx' requires at least one searx instance specified in the 'searxInstances' property below
+             """)]
+    public ImgSearchEngine ImgSearchEngine { get; set; } = ImgSearchEngine.Google;
+
+
+    [Comment("""
+             Which search provider will be used for the `.youtube` and `.q` commands.
+
+             - `ytDataApiv3` - uses google's official youtube data api. Requires `GoogleApiKey` set in creds and youtube data api enabled in developers console
+
+             - `ytdl` - default, uses youtube-dl. Requires `youtube-dl` to be installed and it's path added to env variables. Slow.
+
+             - `ytdlp` - recommended easy, uses `yt-dlp`. Requires `yt-dlp` to be installed and it's path added to env variables
+
+             - `invidious` - recommended advanced, uses invidious api. Requires at least one invidious instance specified in the `invidiousInstances` property
+             """)]
+    public YoutubeSearcher YtProvider { get; set; } = YoutubeSearcher.Ytdlp;
+
+    [Comment("""
+             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.
+             Use a fully qualified url. Example: `https://my-searx-instance.mydomain.com`
+             Instances specified must support 'format=json' query parameter.
+             - In case you're running your own searx instance, set
+
+             search:
+               formats:
+                 - json
+
+             in 'searxng/settings.yml' on your server
+
+             - If you're using a public instance, make sure that the instance you're using supports it (they usually don't)
+             """)]
+    public List<string> SearxInstances { get; set; } = new List<string>();
+
+    [Comment("""
+             Set the invidious instance urls in case you want to use 'invidious' for `.youtube` search
+             Ellie will use a random one for each request.
+             Use a fully qualified url. Example: https://my-invidious-instance.mydomain.com
+
+             Instances specified must have api available.
+             You check that by opening an api endpoint in your browser. For example: https://my-invidious-instance.mydomain.com/api/v1/trending
+             """)]
+    public List<string> InvidiousInstances { get; set; } = new List<string>();
+
+    [Comment("Maximum number of followed streams per server")]
+    public FollowedStreamConfig FollowedStreams { get; set; } = new FollowedStreamConfig();
+}
+
+public sealed class FollowedStreamConfig
+{
+    [Comment("Maximum number of streams that each server can follow. -1 for infinite")]
+    public int MaxCount { get; set; } = 10;
+}
+
+public enum YoutubeSearcher
+{
+    YtDataApiv3,
+    Ytdl,
+    Ytdlp,
+    Invid,
+    Invidious = 3
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs
new file mode 100644
index 0000000..432d116
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/Config/SearchesConfigService.cs
@@ -0,0 +1,66 @@
+using EllieBot.Common.Configs;
+
+namespace EllieBot.Modules.Searches;
+
+public class SearchesConfigService : ConfigServiceBase<SearchesConfig>
+{
+    private static string FILE_PATH = "data/searches.yml";
+    private static readonly TypedKey<SearchesConfig> _changeKey = new("config.searches.updated");
+
+    public override string Name
+        => "searches";
+
+    public SearchesConfigService(IConfigSeria serializer, IPubSub pubSub)
+        : base(FILE_PATH, serializer, pubSub, _changeKey)
+    {
+        AddParsedProp("webEngine",
+            sc => sc.WebSearchEngine,
+            ConfigParsers.InsensitiveEnum,
+            ConfigPrinters.ToString);
+
+        AddParsedProp("imgEngine",
+            sc => sc.ImgSearchEngine,
+            ConfigParsers.InsensitiveEnum,
+            ConfigPrinters.ToString);
+
+        AddParsedProp("ytProvider",
+            sc => sc.YtProvider,
+            ConfigParsers.InsensitiveEnum,
+            ConfigPrinters.ToString);
+
+        AddParsedProp("followedStreams.maxCount",
+            sc => sc.FollowedStreams.MaxCount,
+            int.TryParse,
+            ConfigPrinters.ToString);
+
+        Migrate();
+    }
+
+    private void Migrate()
+    {
+        if (data.Version < 1)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 1;
+                c.WebSearchEngine = WebSearchEngine.Google_Scrape;
+            });
+        }
+
+        if (data.Version < 2)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 2;
+            });
+        }
+        
+        if (data.Version < 3)
+        {
+            ModifyConfig(c =>
+            {
+                c.Version = 3;
+            });
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs b/src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs
new file mode 100644
index 0000000..e924f03
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/Config/WebSearchEngine.cs
@@ -0,0 +1,9 @@
+// ReSharper disable InconsistentNaming
+namespace EllieBot.Modules.Searches;
+
+public enum WebSearchEngine
+{
+    Google,
+    Google_Scrape,
+    Searx,
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/CryptoData.cs b/src/EllieBot/Modules/Searches/_common/CryptoData.cs
new file mode 100644
index 0000000..6600b59
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/CryptoData.cs
@@ -0,0 +1,66 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches.Common;
+
+public class CryptoResponse
+{
+    public List<CmcResponseData> Data { get; set; }
+}
+
+public class CmcQuote
+{
+    [JsonPropertyName("price")]
+    public double Price { get; set; }
+
+    [JsonPropertyName("volume_24h")]
+    public double Volume24h { get; set; }
+
+    // [JsonPropertyName("volume_change_24h")]
+    // public double VolumeChange24h { get; set; }
+    //
+    // [JsonPropertyName("percent_change_1h")]
+    // public double PercentChange1h { get; set; }
+
+    [JsonPropertyName("percent_change_24h")]
+    public double PercentChange24h { get; set; }
+
+    [JsonPropertyName("percent_change_7d")]
+    public double PercentChange7d { get; set; }
+
+    [JsonPropertyName("market_cap")]
+    public double MarketCap { get; set; }
+
+    [JsonPropertyName("market_cap_dominance")]
+    public double MarketCapDominance { get; set; }
+}
+
+public class CmcResponseData
+{
+    [JsonPropertyName("id")]
+    public int Id { get; set; }
+
+    [JsonPropertyName("name")]
+    public string Name { get; set; }
+
+    [JsonPropertyName("symbol")]
+    public string Symbol { get; set; }
+
+    [JsonPropertyName("slug")]
+    public string Slug { get; set; }
+
+    [JsonPropertyName("cmc_rank")]
+    public int CmcRank { get; set; }
+
+    [JsonPropertyName("circulating_supply")]
+    public double? CirculatingSupply { get; set; }
+
+    [JsonPropertyName("total_supply")]
+    public double? TotalSupply { get; set; }
+
+    [JsonPropertyName("max_supply")]
+    public double? MaxSupply { get; set; }
+
+    [JsonPropertyName("quote")]
+    public Dictionary<string, CmcQuote> Quote { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/DefineData.cs b/src/EllieBot/Modules/Searches/_common/DefineData.cs
new file mode 100644
index 0000000..2698d50
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/DefineData.cs
@@ -0,0 +1,10 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Services;
+
+public sealed class DefineData
+{
+    public required string Definition { get; init; }
+    public required string Example { get; init; }
+    public required string WordType { get; init; }
+    public required string Word { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/DefineModel.cs b/src/EllieBot/Modules/Searches/_common/DefineModel.cs
new file mode 100644
index 0000000..a0e2018
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/DefineModel.cs
@@ -0,0 +1,43 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Searches.Common;
+
+public class Audio
+{
+    public string Url { get; set; }
+}
+
+public class Example
+{
+    public List<Audio> Audio { get; set; }
+    public string Text { get; set; }
+}
+
+public class GramaticalInfo
+{
+    public string Type { get; set; }
+}
+
+public class Sens
+{
+    public object Definition { get; set; }
+    public List<Example> Examples { get; set; }
+
+    [JsonProperty("gramatical_info")]
+    public GramaticalInfo GramaticalInfo { get; set; }
+}
+
+public class Result
+{
+    [JsonProperty("part_of_speech")]
+    public string PartOfSpeech { get; set; }
+
+    public List<Sens> Senses { get; set; }
+    public string Url { get; set; }
+}
+
+public class DefineModel
+{
+    public List<Result> Results { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/E621Object.cs b/src/EllieBot/Modules/Searches/_common/E621Object.cs
new file mode 100644
index 0000000..0a68c8b
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/E621Object.cs
@@ -0,0 +1,24 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common;
+
+public class E621Object
+{
+    public FileData File { get; set; }
+    public TagData Tags { get; set; }
+    public ScoreData Score { get; set; }
+
+    public class FileData
+    {
+        public string Url { get; set; }
+    }
+
+    public class TagData
+    {
+        public string[] General { get; set; }
+    }
+
+    public class ScoreData
+    {
+        public string Total { get; set; }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/ErrorType.cs b/src/EllieBot/Modules/Searches/_common/ErrorType.cs
new file mode 100644
index 0000000..0daeea5
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/ErrorType.cs
@@ -0,0 +1,9 @@
+namespace EllieBot.Modules.Searches.Services;
+
+public enum ErrorType
+{
+    InvalidInput,
+    NotFound,
+    Unknown,
+    ApiKeyMissing
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/Exceptions/StreamNotFoundException.cs b/src/EllieBot/Modules/Searches/_common/Exceptions/StreamNotFoundException.cs
new file mode 100644
index 0000000..d941fd3
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/Exceptions/StreamNotFoundException.cs
@@ -0,0 +1,19 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common.Exceptions;
+
+public class StreamNotFoundException : Exception
+{
+    public StreamNotFoundException()
+    {
+    }
+
+    public StreamNotFoundException(string message)
+        : base(message)
+    {
+    }
+
+    public StreamNotFoundException(string message, Exception innerException)
+        : base(message, innerException)
+    {
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/Extensions.cs b/src/EllieBot/Modules/Searches/_common/Extensions.cs
new file mode 100644
index 0000000..d51a9a7
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/Extensions.cs
@@ -0,0 +1,9 @@
+using EllieBot.Db.Models;
+
+namespace EllieBot.Modules.Searches.Common;
+
+public static class Extensions
+{
+    public static StreamDataKey CreateKey(this FollowedStream fs)
+        => new(fs.Type, fs.Username.ToLower());
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/Gallery.cs b/src/EllieBot/Modules/Searches/_common/Gallery.cs
new file mode 100644
index 0000000..656409e
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/Gallery.cs
@@ -0,0 +1,44 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common;
+
+public sealed class Tag
+{
+    public string Name { get; set; }
+    public string Url { get; set; }
+}
+
+public sealed class Gallery
+{
+    public uint Id { get; }
+    public string Url { get; }
+    public string FullTitle { get; }
+    public string Title { get; }
+    public string Thumbnail { get; }
+    public int PageCount { get; }
+    public int Likes { get; }
+    public DateTime UploadedAt { get; }
+    public Tag[] Tags { get; }
+
+
+    public Gallery(
+        uint id,
+        string url,
+        string fullTitle,
+        string title,
+        string thumbnail,
+        int pageCount,
+        int likes,
+        DateTime uploadedAt,
+        Tag[] tags)
+    {
+        Id = id;
+        Url = url;
+        FullTitle = fullTitle;
+        Title = title;
+        Thumbnail = thumbnail;
+        PageCount = pageCount;
+        Likes = likes;
+        UploadedAt = uploadedAt;
+        Tags = tags;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/GatariUserResponse.cs b/src/EllieBot/Modules/Searches/_common/GatariUserResponse.cs
new file mode 100644
index 0000000..63114ab
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/GatariUserResponse.cs
@@ -0,0 +1,52 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Searches.Common;
+
+public class GatariUserData
+{
+    [JsonProperty("abbr")]
+    public object Abbr { get; set; }
+
+    [JsonProperty("clanid")]
+    public object Clanid { get; set; }
+
+    [JsonProperty("country")]
+    public string Country { get; set; }
+
+    [JsonProperty("favourite_mode")]
+    public int FavouriteMode { get; set; }
+
+    [JsonProperty("followers_count")]
+    public int FollowersCount { get; set; }
+
+    [JsonProperty("id")]
+    public int Id { get; set; }
+
+    [JsonProperty("latest_activity")]
+    public int LatestActivity { get; set; }
+
+    [JsonProperty("play_style")]
+    public int PlayStyle { get; set; }
+
+    [JsonProperty("privileges")]
+    public int Privileges { get; set; }
+
+    [JsonProperty("registered_on")]
+    public int RegisteredOn { get; set; }
+
+    [JsonProperty("username")]
+    public string Username { get; set; }
+
+    [JsonProperty("username_aka")]
+    public string UsernameAka { get; set; }
+}
+
+public class GatariUserResponse
+{
+    [JsonProperty("code")]
+    public int Code { get; set; }
+
+    [JsonProperty("users")]
+    public List<GatariUserData> Users { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/GatariUserStatsResponse.cs b/src/EllieBot/Modules/Searches/_common/GatariUserStatsResponse.cs
new file mode 100644
index 0000000..a2f6f89
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/GatariUserStatsResponse.cs
@@ -0,0 +1,76 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Searches.Common;
+
+public class GatariUserStats
+{
+    [JsonProperty("a_count")]
+    public int ACount { get; set; }
+
+    [JsonProperty("avg_accuracy")]
+    public double AvgAccuracy { get; set; }
+
+    [JsonProperty("avg_hits_play")]
+    public double AvgHitsPlay { get; set; }
+
+    [JsonProperty("country_rank")]
+    public int CountryRank { get; set; }
+
+    [JsonProperty("id")]
+    public int Id { get; set; }
+
+    [JsonProperty("level")]
+    public int Level { get; set; }
+
+    [JsonProperty("level_progress")]
+    public int LevelProgress { get; set; }
+
+    [JsonProperty("max_combo")]
+    public int MaxCombo { get; set; }
+
+    [JsonProperty("playcount")]
+    public int Playcount { get; set; }
+
+    [JsonProperty("playtime")]
+    public int Playtime { get; set; }
+
+    [JsonProperty("pp")]
+    public int Pp { get; set; }
+
+    [JsonProperty("rank")]
+    public int? Rank { get; set; }
+
+    [JsonProperty("ranked_score")]
+    public int RankedScore { get; set; }
+
+    [JsonProperty("replays_watched")]
+    public int ReplaysWatched { get; set; }
+
+    [JsonProperty("s_count")]
+    public int SCount { get; set; }
+
+    [JsonProperty("sh_count")]
+    public int ShCount { get; set; }
+
+    [JsonProperty("total_hits")]
+    public int TotalHits { get; set; }
+
+    [JsonProperty("total_score")]
+    public long TotalScore { get; set; }
+
+    [JsonProperty("x_count")]
+    public int XCount { get; set; }
+
+    [JsonProperty("xh_count")]
+    public int XhCount { get; set; }
+}
+
+public class GatariUserStatsResponse
+{
+    [JsonProperty("code")]
+    public int Code { get; set; }
+
+    [JsonProperty("stats")]
+    public GatariUserStats Stats { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/GoogleSearchResult.cs b/src/EllieBot/Modules/Searches/_common/GoogleSearchResult.cs
new file mode 100644
index 0000000..23a4d15
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/GoogleSearchResult.cs
@@ -0,0 +1,16 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common;
+
+public sealed class GoogleSearchResult
+{
+    public string Title { get; }
+    public string Link { get; }
+    public string Text { get; }
+
+    public GoogleSearchResult(string title, string link, string text)
+    {
+        Title = title;
+        Link = link;
+        Text = text;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/HearthstoneCardData.cs b/src/EllieBot/Modules/Searches/_common/HearthstoneCardData.cs
new file mode 100644
index 0000000..3231cf4
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/HearthstoneCardData.cs
@@ -0,0 +1,13 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common;
+
+public class HearthstoneCardData
+{
+    public string Text { get; set; }
+    public string Flavor { get; set; }
+    public bool Collectible { get; set; }
+
+    public string Img { get; set; }
+    public string ImgGold { get; set; }
+    public string PlayerClass { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/LowerCaseNamingPolicy.cs b/src/EllieBot/Modules/Searches/_common/LowerCaseNamingPolicy.cs
new file mode 100644
index 0000000..3507e27
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/LowerCaseNamingPolicy.cs
@@ -0,0 +1,12 @@
+#nullable disable
+using System.Text.Json;
+
+namespace EllieBot.Modules.Searches.Common;
+
+public class LowerCaseNamingPolicy : JsonNamingPolicy
+{
+    public static LowerCaseNamingPolicy Default = new();
+
+    public override string ConvertName(string name)
+        => name.ToLower();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/MagicItem.cs b/src/EllieBot/Modules/Searches/_common/MagicItem.cs
new file mode 100644
index 0000000..e3e91c5
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/MagicItem.cs
@@ -0,0 +1,8 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common;
+
+public class MagicItem
+{
+    public string Name { get; set; }
+    public string Description { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/MtgData.cs b/src/EllieBot/Modules/Searches/_common/MtgData.cs
new file mode 100644
index 0000000..7daa3a2
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/MtgData.cs
@@ -0,0 +1,26 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common;
+
+public class MtgData
+{
+    public string Name { get; set; }
+    public string Description { get; set; }
+    public string ImageUrl { get; set; }
+    public string StoreUrl { get; set; }
+    public string Types { get; set; }
+    public string ManaCost { get; set; }
+}
+
+public class MtgResponse
+{
+    public List<Data> Cards { get; set; }
+
+    public class Data
+    {
+        public string Name { get; set; }
+        public string ManaCost { get; set; }
+        public string Text { get; set; }
+        public List<string> Types { get; set; }
+        public string ImageUrl { get; set; }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/NovelData.cs b/src/EllieBot/Modules/Searches/_common/NovelData.cs
new file mode 100644
index 0000000..3f36702
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/NovelData.cs
@@ -0,0 +1,14 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common;
+
+public class NovelResult
+{
+    public string Description { get; set; }
+    public string Title { get; set; }
+    public string Link { get; set; }
+    public string ImageUrl { get; set; }
+    public string[] Authors { get; set; }
+    public string Status { get; set; }
+    public string[] Genres { get; set; }
+    public string Score { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/OmdbMovie.cs b/src/EllieBot/Modules/Searches/_common/OmdbMovie.cs
new file mode 100644
index 0000000..2680275
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/OmdbMovie.cs
@@ -0,0 +1,13 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common;
+
+public class OmdbMovie
+{
+    public string Title { get; set; }
+    public string Year { get; set; }
+    public string ImdbRating { get; set; }
+    public string ImdbId { get; set; }
+    public string Genre { get; set; }
+    public string Plot { get; set; }
+    public string Poster { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/OsuMapData.cs b/src/EllieBot/Modules/Searches/_common/OsuMapData.cs
new file mode 100644
index 0000000..0466daf
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/OsuMapData.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace EllieBot.Common;
+
+public class OsuMapData
+{
+    public string Title { get; set; }
+    public string Artist { get; set; }
+    public string Version { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/OsuUserBets.cs b/src/EllieBot/Modules/Searches/_common/OsuUserBets.cs
new file mode 100644
index 0000000..d030c78
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/OsuUserBets.cs
@@ -0,0 +1,58 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Common;
+
+public class OsuUserBests
+{
+    [JsonProperty("beatmap_id")]
+    public string BeatmapId { get; set; }
+
+    [JsonProperty("score_id")]
+    public string ScoreId { get; set; }
+
+    [JsonProperty("score")]
+    public string Score { get; set; }
+
+    [JsonProperty("maxcombo")]
+    public string Maxcombo { get; set; }
+
+    [JsonProperty("count50")]
+    public double Count50 { get; set; }
+
+    [JsonProperty("count100")]
+    public double Count100 { get; set; }
+
+    [JsonProperty("count300")]
+    public double Count300 { get; set; }
+
+    [JsonProperty("countmiss")]
+    public int Countmiss { get; set; }
+
+    [JsonProperty("countkatu")]
+    public double Countkatu { get; set; }
+
+    [JsonProperty("countgeki")]
+    public double Countgeki { get; set; }
+
+    [JsonProperty("perfect")]
+    public string Perfect { get; set; }
+
+    [JsonProperty("enabled_mods")]
+    public int EnabledMods { get; set; }
+
+    [JsonProperty("user_id")]
+    public string UserId { get; set; }
+
+    [JsonProperty("date")]
+    public string Date { get; set; }
+
+    [JsonProperty("rank")]
+    public string Rank { get; set; }
+
+    [JsonProperty("pp")]
+    public double Pp { get; set; }
+
+    [JsonProperty("replay_available")]
+    public string ReplayAvailable { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/OsuUserData.cs b/src/EllieBot/Modules/Searches/_common/OsuUserData.cs
new file mode 100644
index 0000000..86f1000
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/OsuUserData.cs
@@ -0,0 +1,73 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Searches.Common;
+
+public class OsuUserData
+{
+    [JsonProperty("user_id")]
+    public string UserId { get; set; }
+
+    [JsonProperty("username")]
+    public string Username { get; set; }
+
+    [JsonProperty("join_date")]
+    public string JoinDate { get; set; }
+
+    [JsonProperty("count300")]
+    public string Count300 { get; set; }
+
+    [JsonProperty("count100")]
+    public string Count100 { get; set; }
+
+    [JsonProperty("count50")]
+    public string Count50 { get; set; }
+
+    [JsonProperty("playcount")]
+    public string Playcount { get; set; }
+
+    [JsonProperty("ranked_score")]
+    public string RankedScore { get; set; }
+
+    [JsonProperty("total_score")]
+    public string TotalScore { get; set; }
+
+    [JsonProperty("pp_rank")]
+    public string PpRank { get; set; }
+
+    [JsonProperty("level")]
+    public double Level { get; set; }
+
+    [JsonProperty("pp_raw")]
+    public double PpRaw { get; set; }
+
+    [JsonProperty("accuracy")]
+    public double Accuracy { get; set; }
+
+    [JsonProperty("count_rank_ss")]
+    public string CountRankSs { get; set; }
+
+    [JsonProperty("count_rank_ssh")]
+    public string CountRankSsh { get; set; }
+
+    [JsonProperty("count_rank_s")]
+    public string CountRankS { get; set; }
+
+    [JsonProperty("count_rank_sh")]
+    public string CountRankSh { get; set; }
+
+    [JsonProperty("count_rank_a")]
+    public string CountRankA { get; set; }
+
+    [JsonProperty("country")]
+    public string Country { get; set; }
+
+    [JsonProperty("total_seconds_played")]
+    public string TotalSecondsPlayed { get; set; }
+
+    [JsonProperty("pp_country_rank")]
+    public string PpCountryRank { get; set; }
+    
+    [JsonIgnore]
+    public int ModeNumber { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/PathOfExileModels.cs b/src/EllieBot/Modules/Searches/_common/PathOfExileModels.cs
new file mode 100644
index 0000000..dcedaa8
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/PathOfExileModels.cs
@@ -0,0 +1,40 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Searches.Common;
+
+public class Account
+{
+    [JsonProperty("name")]
+    public string Name { get; set; }
+
+    [JsonProperty("league")]
+    public string League { get; set; }
+
+    [JsonProperty("classId")]
+    public int ClassId { get; set; }
+
+    [JsonProperty("ascendancyClass")]
+    public int AscendancyClass { get; set; }
+
+    [JsonProperty("class")]
+    public string Class { get; set; }
+
+    [JsonProperty("level")]
+    public int Level { get; set; }
+}
+
+public class Leagues
+{
+    [JsonProperty("id")]
+    public string Id { get; set; }
+
+    [JsonProperty("url")]
+    public string Url { get; set; }
+
+    [JsonProperty("startAt")]
+    public DateTime StartAt { get; set; }
+
+    [JsonProperty("endAt")]
+    public object EndAt { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/ShortenData.cs b/src/EllieBot/Modules/Searches/_common/ShortenData.cs
new file mode 100644
index 0000000..872347e
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/ShortenData.cs
@@ -0,0 +1,10 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Searches.Services;
+
+public class ShortenData
+{
+    [JsonProperty("result_url")]
+    public string ResultUrl { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/SteamGameId.cs b/src/EllieBot/Modules/Searches/_common/SteamGameId.cs
new file mode 100644
index 0000000..b618b32
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/SteamGameId.cs
@@ -0,0 +1,35 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Searches;
+
+public class SteamGameId
+{
+    [JsonProperty("name")]
+    public string Name { get; set; }
+
+    [JsonProperty("appid")]
+    public int AppId { get; set; }
+}
+
+public class SteamGameData
+{
+    public string ShortDescription { get; set; }
+
+    public class Container
+    {
+        [JsonProperty("success")]
+        public bool Success { get; set; }
+
+        [JsonProperty("data")]
+        public SteamGameData Data { get; set; }
+    }
+}
+
+public enum TimeErrors
+{
+    InvalidInput,
+    ApiKeyMissing,
+    NotFound,
+    Unknown
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/HelixStreamsResponse.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/HelixStreamsResponse.cs
new file mode 100644
index 0000000..c5e1557
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/HelixStreamsResponse.cs
@@ -0,0 +1,64 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.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; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/HelixUsersResponse.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/HelixUsersResponse.cs
new file mode 100644
index 0000000..1258c3e
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/HelixUsersResponse.cs
@@ -0,0 +1,46 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.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; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/PicartoChannelResponse.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/PicartoChannelResponse.cs
new file mode 100644
index 0000000..75fd85a
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/PicartoChannelResponse.cs
@@ -0,0 +1,157 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.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; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/StreamData.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/StreamData.cs
new file mode 100644
index 0000000..78a6f10
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/StreamData.cs
@@ -0,0 +1,21 @@
+#nullable disable
+using EllieBot.Db.Models;
+
+namespace EllieBot.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());
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/StreamDataKey.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/StreamDataKey.cs
new file mode 100644
index 0000000..e946185
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/StreamDataKey.cs
@@ -0,0 +1,16 @@
+#nullable disable
+using EllieBot.Db.Models;
+
+namespace EllieBot.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;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoGetUsersResponse.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoGetUsersResponse.cs
new file mode 100644
index 0000000..d878a71
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoGetUsersResponse.cs
@@ -0,0 +1,61 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.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; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoRequestData.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoRequestData.cs
new file mode 100644
index 0000000..bbdaf46
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoRequestData.cs
@@ -0,0 +1,10 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches.Common.StreamNotifications.Providers;
+
+public class TrovoRequestData
+{
+    [JsonPropertyName("username")]
+    public string Username { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoSocialLink.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoSocialLink.cs
new file mode 100644
index 0000000..b01bd8c
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TrovoSocialLink.cs
@@ -0,0 +1,13 @@
+#nullable disable
+using System.Text.Json.Serialization;
+
+namespace EllieBot.Modules.Searches.Common.StreamNotifications.Providers;
+
+public class TrovoSocialLink
+{
+    [JsonPropertyName("type")]
+    public string Type { get; set; }
+
+    [JsonPropertyName("url")]
+    public string Url { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TwitchResponseV5.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TwitchResponseV5.cs
new file mode 100644
index 0000000..f49b1b3
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TwitchResponseV5.cs
@@ -0,0 +1,114 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.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; }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TwitchUsersResponseV5.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TwitchUsersResponseV5.cs
new file mode 100644
index 0000000..4cfbcb4
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Models/TwitchUsersResponseV5.cs
@@ -0,0 +1,37 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.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; } 
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/NotifChecker.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/NotifChecker.cs
new file mode 100644
index 0000000..1c06296
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/NotifChecker.cs
@@ -0,0 +1,215 @@
+using EllieBot.Db.Models;
+using EllieBot.Modules.Searches.Common.StreamNotifications.Providers;
+
+namespace EllieBot.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);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/PicartoProvider.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/PicartoProvider.cs
new file mode 100644
index 0000000..a619398
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/PicartoProvider.cs
@@ -0,0 +1,100 @@
+using EllieBot.Db.Models;
+using Newtonsoft.Json;
+using System.Text.RegularExpressions;
+
+namespace EllieBot.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([login]);
+
+        return data.FirstOrDefault();
+    }
+
+    public override async Task<IReadOnlyCollection<StreamData>> GetStreamDataAsync(List<string> logins)
+    {
+        if (logins.Count == 0)
+            return [];
+
+        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
+        };
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/Provider.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/Provider.cs
new file mode 100644
index 0000000..a3e1adb
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/Provider.cs
@@ -0,0 +1,63 @@
+using EllieBot.Db.Models;
+
+namespace EllieBot.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();
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/TrovoProvider.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/TrovoProvider.cs
new file mode 100644
index 0000000..4e42ba4
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/TrovoProvider.cs
@@ -0,0 +1,126 @@
+using EllieBot.Db.Models;
+using System.Net.Http.Json;
+using System.Text.RegularExpressions;
+
+namespace EllieBot.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;
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/TwitchHelixProvider.cs b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/TwitchHelixProvider.cs
new file mode 100644
index 0000000..a3c92c6
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/StreamNotifications/Providers/TwitchHelixProvider.cs
@@ -0,0 +1,194 @@
+using EllieBot.Db.Models;
+using System.Text.RegularExpressions;
+using TwitchLib.Api;
+using JsonSerializer = System.Text.Json.JsonSerializer;
+
+namespace EllieBot.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([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 [];
+            }
+        }
+        
+        // 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 [];
+            }
+        }
+
+        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,
+        };
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/TimeData.cs b/src/EllieBot/Modules/Searches/_common/TimeData.cs
new file mode 100644
index 0000000..fdb159f
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/TimeData.cs
@@ -0,0 +1,9 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common;
+
+public class TimeData
+{
+    public string Address { get; set; }
+    public DateTime Time { get; set; }
+    public string TimeZoneName { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/TimeModels.cs b/src/EllieBot/Modules/Searches/_common/TimeModels.cs
new file mode 100644
index 0000000..8125b8e
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/TimeModels.cs
@@ -0,0 +1,22 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Searches.Common;
+
+public class TimeZoneResult
+{
+    [JsonProperty("abbreviation")]
+    public string TimezoneName { get; set; }
+
+    [JsonProperty("timestamp")]
+    public int Timestamp { get; set; }
+}
+
+public class LocationIqResponse
+{
+    public float Lat { get; set; }
+    public float Lon { get; set; }
+
+    [JsonProperty("display_name")]
+    public string DisplayName { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/UrbanDef.cs b/src/EllieBot/Modules/Searches/_common/UrbanDef.cs
new file mode 100644
index 0000000..ef222df
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/UrbanDef.cs
@@ -0,0 +1,14 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common;
+
+public class UrbanResponse
+{
+    public UrbanDef[] List { get; set; }
+}
+
+public class UrbanDef
+{
+    public string Word { get; set; }
+    public string Definition { get; set; }
+    public string Permalink { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/WeatherModels.cs b/src/EllieBot/Modules/Searches/_common/WeatherModels.cs
new file mode 100644
index 0000000..626d548
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/WeatherModels.cs
@@ -0,0 +1,67 @@
+#nullable disable
+using Newtonsoft.Json;
+
+namespace EllieBot.Modules.Searches.Common;
+
+public class Coord
+{
+    public double Lon { get; set; }
+    public double Lat { get; set; }
+}
+
+public class Weather
+{
+    public int Id { get; set; }
+    public string Main { get; set; }
+    public string Description { get; set; }
+    public string Icon { get; set; }
+}
+
+public class Main
+{
+    public double Temp { get; set; }
+    public float Pressure { get; set; }
+    public float Humidity { get; set; }
+
+    [JsonProperty("temp_min")]
+    public double TempMin { get; set; }
+
+    [JsonProperty("temp_max")]
+    public double TempMax { get; set; }
+}
+
+public class Wind
+{
+    public double Speed { get; set; }
+    public double Deg { get; set; }
+}
+
+public class Clouds
+{
+    public int All { get; set; }
+}
+
+public class Sys
+{
+    public int Type { get; set; }
+    public int Id { get; set; }
+    public double Message { get; set; }
+    public string Country { get; set; }
+    public double Sunrise { get; set; }
+    public double Sunset { get; set; }
+}
+
+public class WeatherData
+{
+    public Coord Coord { get; set; }
+    public List<Weather> Weather { get; set; }
+    public Main Main { get; set; }
+    public int Visibility { get; set; }
+    public Wind Wind { get; set; }
+    public Clouds Clouds { get; set; }
+    public int Dt { get; set; }
+    public Sys Sys { get; set; }
+    public int Id { get; set; }
+    public string Name { get; set; }
+    public int Cod { get; set; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/WikiaResponse.cs b/src/EllieBot/Modules/Searches/_common/WikiaResponse.cs
new file mode 100644
index 0000000..d0b3960
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/WikiaResponse.cs
@@ -0,0 +1,7 @@
+namespace EllieBot.Modules.Searches.Services;
+
+public sealed class WikiaResponse
+{
+    public required string Url { get; init; }
+    public required string Title { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/WikipediaApiModel.cs b/src/EllieBot/Modules/Searches/_common/WikipediaApiModel.cs
new file mode 100644
index 0000000..7cf8105
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/WikipediaApiModel.cs
@@ -0,0 +1,18 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common;
+
+public class WikipediaApiModel
+{
+    public WikipediaQuery Query { get; set; }
+
+    public class WikipediaQuery
+    {
+        public WikipediaPage[] Pages { get; set; }
+
+        public class WikipediaPage
+        {
+            public bool Missing { get; set; } = false;
+            public string FullUrl { get; set; }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/WikipediaReply.cs b/src/EllieBot/Modules/Searches/_common/WikipediaReply.cs
new file mode 100644
index 0000000..3969090
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/WikipediaReply.cs
@@ -0,0 +1,11 @@
+namespace EllieBot.Modules.Searches.Services;
+
+public class WikipediaReply
+{
+    public class Info
+    {
+        public required string Url { get; init; }
+    }
+
+    public required Info Data { get; init; }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Searches/_common/WoWJoke.cs b/src/EllieBot/Modules/Searches/_common/WoWJoke.cs
new file mode 100644
index 0000000..78a6030
--- /dev/null
+++ b/src/EllieBot/Modules/Searches/_common/WoWJoke.cs
@@ -0,0 +1,11 @@
+#nullable disable
+namespace EllieBot.Modules.Searches.Common;
+
+public class WoWJoke
+{
+    public string Question { get; set; }
+    public string Answer { get; set; }
+
+    public override string ToString()
+        => $"`{Question}`\n\n**{Answer}**";
+}
\ No newline at end of file