28 Commits
v0.3 ... v0.4.1

Author SHA1 Message Date
pcjones
f02547c0e3 Merge branch 'master' of https://github.com/PCJones/UmlautAdaptarr 2024-03-15 18:25:08 +01:00
pcjones
61e93b5b24 Enhance searchitem matching 2024-03-15 18:24:39 +01:00
pcjones
f88daf4955 Lower minimum delay between requests to 1 second 2024-03-08 10:00:29 +01:00
Jonas F
93c667422f Update README.md 2024-03-06 20:03:16 +01:00
Jonas F
e1978d869c Merge pull request #12 from PCJones/develop
v0.4
2024-03-06 20:00:05 +01:00
pcjones
cfdfa89009 Merge branch 'develop' 2024-03-06 19:52:19 +01:00
pcjones
9bee42d7dd Detect media Type by category ID 2024-03-06 19:52:07 +01:00
pcjones
797ff2b97e Fix searchItem title not being logged 2024-03-01 12:53:47 +01:00
pcjones
a67d5c2d1e Merge branch 'develop' 2024-02-29 17:20:23 +01:00
pcjones
d1d05f8264 Fix url ending with / not being recognized as valid 2024-02-29 17:17:55 +01:00
Jonas F
939b902be3 Update README.md 2024-02-26 21:33:45 +01:00
Jonas F
f56d071642 Update README.md 2024-02-26 21:26:15 +01:00
Jonas F
7513e7d227 Update README.md 2024-02-26 21:23:26 +01:00
Jonas F
7a791dab23 Update README.md 2024-02-26 21:23:00 +01:00
Jonas F
402a4deba3 Update README.md 2024-02-26 21:21:05 +01:00
Jonas F
d31508fef3 Update README.md 2024-02-26 21:20:33 +01:00
pcjones
1c329e886d Update README 2024-02-24 16:33:50 +01:00
pcjones
6932c4b2f8 Add hyphen title matching if title contains colon 2024-02-23 14:09:20 +01:00
pcjones
ff051569ca Fix separator being added twice 2024-02-23 14:08:56 +01:00
pcjones
dbac09bf36 Merge branch 'master' of https://github.com/PCJones/UmlautAdaptarr 2024-02-23 12:25:51 +01:00
pcjones
0bde5d5d24 Fix sync process not finishing if there was an error 2024-02-23 12:25:42 +01:00
Jonas F
99af842fc6 Update README.md 2024-02-20 15:16:19 +01:00
pcjones
fbfbeadb3e set FORCE_TEXT_SEARCH_ORIGINAL_TITLE to true by default 2024-02-19 21:35:16 +01:00
pcjones
7cfae00511 Fix SearchItem lookup not working for newly added items in Readarr and Lidarr 2024-02-19 21:04:32 +01:00
pcjones
cac920ae88 Merge branch 'master' of https://github.com/PCJones/UmlautAdaptarr 2024-02-19 14:20:27 +01:00
pcjones
bab60771a4 Merge branch 'develop' 2024-02-19 14:20:10 +01:00
pcjones
828faae486 Lower minimum delay between two requests to 1.5 seconds; no longer use delay if cache can be used 2024-02-19 14:19:57 +01:00
Jonas F
333a18ecd5 Update README.md 2024-02-19 14:07:47 +01:00
11 changed files with 118 additions and 40 deletions

View File

@@ -5,7 +5,7 @@
## Erste Testversion ## Erste Testversion
Wer möchte kann den UmlautAdaptarr jetzt gerne testen! Über Feedback würde ich mich sehr freuen! Wer möchte kann den UmlautAdaptarr jetzt gerne testen! Über Feedback würde ich mich sehr freuen!
Es sollte mit allen *arrs funktionieren, hat aber nur bei Sonarr und Lidarr schon Auswirkungen (abgesehen vom Caching). Es sollte mit allen *arrs funktionieren, hat aber nur bei Sonarr, Readarr und Lidarr schon Auswirkungen (abgesehen vom Caching).
Momentan ist docker dafür nötig, wer kein Docker nutzt muss sich noch etwas gedulden. Momentan ist docker dafür nötig, wer kein Docker nutzt muss sich noch etwas gedulden.
@@ -30,7 +30,7 @@ UmlautAdaptarr löst mehrere Probleme:
# Wie macht UmlautAdaptarr das? # Wie macht UmlautAdaptarr das?
UmlautAdaptarr tut so, als wäre es ein Indexer. In Wahrheit schaltet sich UmlautAdaptarr aber nur zwischen die *arrs und den echten Indexer und kann somit die Suchen sowie die Ergebnisse abfangen und bearbeiten. UmlautAdaptarr tut so, als wäre es ein Indexer. In Wahrheit schaltet sich UmlautAdaptarr aber nur zwischen die *arrs und den echten Indexer und kann somit die Suchen sowie die Ergebnisse abfangen und bearbeiten.
Am Ende werden die gefundenen Releases immer so umbenannt, das die Arrs sie einwandfrei erkennen. Am Ende werden die gefundenen Releases immer so umbenannt, dass die Arrs sie einwandfrei erkennen.
Einige Beispiele findet ihr unter Features. Einige Beispiele findet ihr unter Features.
@@ -38,15 +38,17 @@ Einige Beispiele findet ihr unter Features.
| Feature | Status | | Feature | Status |
|-------------------------------------------------------------------|---------------| |-------------------------------------------------------------------|---------------|
| Prowlarr Support | ✓| | Prowlarr & NZB Hydra Support | ✓|
| Sonarr Support | ✓ | | Sonarr Support | ✓ |
| Lidarr Support | ✓| | Lidarr Support | ✓|
| Readarr Support | ✓ |
| Releases mit deutschem Titel werden erkannt | ✓ | | Releases mit deutschem Titel werden erkannt | ✓ |
| Releases mit TVDB-Alias Titel werden erkannt | ✓ | | Releases mit TVDB-Alias Titel werden erkannt | ✓ |
| Korrekte Suche und Erkennung von Titel mit Umlauten | ✓ | | Korrekte Suche und Erkennung von Titel mit Umlauten | ✓ |
| Anfragen-Caching für 5 Minuten zur Reduzierung der API-Zugriffe | ✓ | | Anfragen-Caching für 5 Minuten zur Reduzierung der API-Zugriffe | ✓ |
| Usenet (newznab) Support |✓|
| Torrent (torznab) Support |✓|
| Radarr Support | Geplant | | Radarr Support | Geplant |
| Readarr Support | Geplant |
| Prowlarr Unterstützung für "DE" SceneNZBs Kategorien | Geplant | | Prowlarr Unterstützung für "DE" SceneNZBs Kategorien | Geplant |
| Unterstützung weiterer Sprachen neben Deutsch | Geplant | | Unterstützung weiterer Sprachen neben Deutsch | Geplant |
| Wünsche? | Vorschläge? | | Wünsche? | Vorschläge? |
@@ -87,6 +89,12 @@ Sonarr erwartet immer den Englischen Namen, der hier natürlich nicht gegeben is
- [Telegram](https://t.me/pc_jones) - [Telegram](https://t.me/pc_jones)
- Discord: pcjones1 - oder komm in den UsenetDE Discord Server: [https://discord.gg/pZrrMcJMQM](https://discord.gg/pZrrMcJMQM) - Discord: pcjones1 - oder komm in den UsenetDE Discord Server: [https://discord.gg/pZrrMcJMQM](https://discord.gg/pZrrMcJMQM)
## Spenden
Über eine Spende freue ich mich natürlich immer :D
PayPal: https://paypal.me/pcjones1
Für andere Spendenmöglichkeiten gerne auf Discord oder Telegram melden - danke!
### Licenses & Metadata source ### Licenses & Metadata source
- TV Metadata source: https://thetvdb.com - TV Metadata source: https://thetvdb.com
- Movie Metadata source: https://themoviedb.org - Movie Metadata source: https://themoviedb.org

View File

@@ -8,7 +8,8 @@ namespace UmlautAdaptarr.Controllers
{ {
public abstract class SearchControllerBase(ProxyService proxyService, TitleMatchingService titleMatchingService) : ControllerBase public abstract class SearchControllerBase(ProxyService proxyService, TitleMatchingService titleMatchingService) : ControllerBase
{ {
private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = false; // TODO evaluate if this should be set to true by default
private readonly bool TODO_FORCE_TEXT_SEARCH_ORIGINAL_TITLE = true;
private readonly bool TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE = false; private readonly bool TODO_FORCE_TEXT_SEARCH_GERMAN_TITLE = false;
protected async Task<IActionResult> BaseSearch(string options, protected async Task<IActionResult> BaseSearch(string options,
string domain, string domain,
@@ -52,7 +53,7 @@ namespace UmlautAdaptarr.Controllers
var titleSearchVariations = new List<string>(searchItem?.TitleSearchVariations); var titleSearchVariations = new List<string>(searchItem?.TitleSearchVariations);
string searchQuery = string.Empty; var searchQuery = string.Empty;
if (queryParameters.TryGetValue("q", out string? q)) if (queryParameters.TryGetValue("q", out string? q))
{ {
searchQuery = q ?? string.Empty; searchQuery = q ?? string.Empty;
@@ -185,7 +186,6 @@ namespace UmlautAdaptarr.Controllers
if (categories.Split(',').Any(category => READARR_CATEGORY_IDS.Contains(category))) if (categories.Split(',').Any(category => READARR_CATEGORY_IDS.Contains(category)))
{ {
var mediaType = "book"; var mediaType = "book";
// TODO rename function or use own
searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, title.GetReadarrTitleForExternalId()); searchItem = await searchItemLookupService.GetOrFetchSearchItemByExternalId(mediaType, title.GetReadarrTitleForExternalId());
} }

View File

@@ -43,7 +43,30 @@ namespace UmlautAdaptarr.Models
} }
else else
{ {
GenerateVariationsForTV(germanTitle, mediaType, aliases); // if mediatype is movie/tv and the Expected Title ends with a year but the german title doesn't then append the year to the german title and to aliases
// example: https://thetvdb.com/series/385925-avatar-the-last-airbender -> german Title is without 2024
var yearAtEndOfTitleMatch = YearAtEndOfTitleRegex().Match(expectedTitle);
if (yearAtEndOfTitleMatch.Success)
{
string year = yearAtEndOfTitleMatch.Value[1..^1];
if (GermanTitle != null && !GermanTitle.Contains(year))
{
GermanTitle = $"{germanTitle} {year}";
}
if (aliases != null)
{
for (int i = 0; i < aliases.Length; i++)
{
if (!aliases[i].Contains(year))
{
aliases[i] = $"{aliases[i]} {year}";
}
}
}
}
GenerateVariationsForTV(GermanTitle, mediaType, aliases);
} }
} }
@@ -60,6 +83,12 @@ namespace UmlautAdaptarr.Models
foreach (var alias in aliases) foreach (var alias in aliases)
{ {
allTitleVariations.AddRange(GenerateVariations(alias, mediaType)); allTitleVariations.AddRange(GenerateVariations(alias, mediaType));
// If title contains ":" also match for "-"
if (alias.Contains(':'))
{
allTitleVariations.Add(alias.Replace(":", " -"));
}
} }
} }
@@ -79,6 +108,12 @@ namespace UmlautAdaptarr.Models
} }
// If title contains ":" also match for "-"
if (germanTitle?.Contains(':') ?? false)
{
allTitleVariations.Add(germanTitle.Replace(":", " -"));
}
TitleMatchVariations = allTitleVariations.Distinct(StringComparer.InvariantCultureIgnoreCase).ToArray(); TitleMatchVariations = allTitleVariations.Distinct(StringComparer.InvariantCultureIgnoreCase).ToArray();
} }
@@ -123,6 +158,7 @@ namespace UmlautAdaptarr.Models
{ {
return []; return [];
} }
var cleanTitle = title.GetCleanTitle(); var cleanTitle = title.GetCleanTitle();
if (cleanTitle?.Length == 0) if (cleanTitle?.Length == 0)
@@ -180,5 +216,8 @@ namespace UmlautAdaptarr.Models
return cleanedVariations.Distinct(); return cleanedVariations.Distinct();
} }
[GeneratedRegex(@"\(\d{4}\)$")]
private static partial Regex YearAtEndOfTitleRegex();
} }
} }

View File

@@ -22,7 +22,6 @@ namespace UmlautAdaptarr.Providers
try try
{ {
var sonarrUrl = $"{_sonarrHost}/api/v3/series?includeSeasonImages=false&apikey={_sonarrApiKey}"; var sonarrUrl = $"{_sonarrHost}/api/v3/series?includeSeasonImages=false&apikey={_sonarrApiKey}";
logger.LogInformation($"Fetching all items from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}"); logger.LogInformation($"Fetching all items from Sonarr: {UrlUtilities.RedactApiKey(sonarrUrl)}");
var response = await httpClient.GetStringAsync(sonarrUrl); var response = await httpClient.GetStringAsync(sonarrUrl);
@@ -99,7 +98,7 @@ namespace UmlautAdaptarr.Providers
mediaType: _mediaType mediaType: _mediaType
); );
logger.LogInformation($"Successfully fetched show {searchItem} from Sonarr."); logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr.");
return searchItem; return searchItem;
} }
} }
@@ -157,7 +156,7 @@ namespace UmlautAdaptarr.Providers
mediaType: _mediaType mediaType: _mediaType
); );
logger.LogInformation($"Successfully fetched show {searchItem} from Sonarr."); logger.LogInformation($"Successfully fetched show {searchItem.Title} from Sonarr.");
return searchItem; return searchItem;
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -64,15 +64,18 @@ namespace UmlautAdaptarr.Services
var success = true; var success = true;
if (_readarrEnabled) if (_readarrEnabled)
{ {
success = success && await FetchItemsFromReadarrAsync(); var syncSuccess = await FetchItemsFromReadarrAsync();
success = success && syncSuccess;
} }
if (_sonarrEnabled) if (_sonarrEnabled)
{ {
success = success && await FetchItemsFromSonarrAsync(); var syncSuccess = await FetchItemsFromSonarrAsync();
success = success && syncSuccess;
} }
if (_lidarrEnabled) if (_lidarrEnabled)
{ {
success = success && await FetchItemsFromLidarrAsync(); var syncSuccess = await FetchItemsFromLidarrAsync();
success = success && syncSuccess;
} }
return success; return success;
} }

View File

@@ -96,30 +96,51 @@ namespace UmlautAdaptarr.Services
// Use the first few characters of the normalized title for cache prefix search // Use the first few characters of the normalized title for cache prefix search
var cacheSearchPrefix = normalizedTitle[..Math.Min(VARIATION_LOOKUP_CACHE_LENGTH, normalizedTitle.Length)]; var cacheSearchPrefix = normalizedTitle[..Math.Min(VARIATION_LOOKUP_CACHE_LENGTH, normalizedTitle.Length)];
SearchItem? bestSearchItemMatch = null;
var bestVariationMatchLength = 0;
HashSet<string> checkedSearchItems = [];
if (VariationIndex.TryGetValue(cacheSearchPrefix, out var cacheKeys)) if (VariationIndex.TryGetValue(cacheSearchPrefix, out var cacheKeys))
{ {
foreach (var cacheKey in cacheKeys) foreach (var cacheKey in cacheKeys)
{ {
if (cache.TryGetValue(cacheKey, out SearchItem? item)) if (cache.TryGetValue(cacheKey, out SearchItem? item))
{ {
if (item?.MediaType != mediaType) if (item == null || item.MediaType != mediaType)
{ {
continue; continue;
} }
var searchItemIdentifier = $"{item.MediaType}_{item.ExternalId}";
if (checkedSearchItems.Contains(searchItemIdentifier))
{
continue;
}
else
{
checkedSearchItems.Add(searchItemIdentifier);
}
// After finding a potential item, compare normalizedTitle with each German title variation // After finding a potential item, compare normalizedTitle with each German title variation
foreach (var variation in item?.TitleMatchVariations ?? []) foreach (var variation in item.TitleMatchVariations ?? [])
{ {
var normalizedVariation = variation.RemoveAccentButKeepGermanUmlauts().ToLower(); var normalizedVariation = variation.RemoveAccentButKeepGermanUmlauts().ToLower();
if (normalizedTitle.StartsWith(variation, StringComparison.OrdinalIgnoreCase)) if (normalizedTitle.StartsWith(variation, StringComparison.OrdinalIgnoreCase))
{ {
return item; // If we find a variation match that is "longer" then most likely that one is correct and the earlier match was wrong (if it was from another searchItem)
if (variation.Length > bestVariationMatchLength)
{
bestSearchItemMatch = item;
bestVariationMatchLength = variation.Length;
}
} }
} }
} }
} }
} }
return null; return bestSearchItemMatch;
} }
public SearchItem? GetSearchItemByExternalId(string mediaType, string externalId) public SearchItem? GetSearchItemByExternalId(string mediaType, string externalId)

View File

@@ -11,6 +11,7 @@ namespace UmlautAdaptarr.Services
private readonly ILogger<ProxyService> _logger; private readonly ILogger<ProxyService> _logger;
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private static readonly ConcurrentDictionary<string, DateTimeOffset> _lastRequestTimes = new(); private static readonly ConcurrentDictionary<string, DateTimeOffset> _lastRequestTimes = new();
private static readonly TimeSpan MINIMUM_DELAY_FOR_SAME_HOST = new(0, 0, 0, 1);
public ProxyService(IHttpClientFactory clientFactory, IConfiguration configuration, ILogger<ProxyService> logger, IMemoryCache cache) public ProxyService(IHttpClientFactory clientFactory, IConfiguration configuration, ILogger<ProxyService> logger, IMemoryCache cache)
{ {
@@ -20,6 +21,20 @@ namespace UmlautAdaptarr.Services
_cache = cache; _cache = cache;
} }
private static async Task EnsureMinimumDelayAsync(string targetUri)
{
var host = new Uri(targetUri).Host;
if (_lastRequestTimes.TryGetValue(host, out var lastRequestTime))
{
var timeSinceLastRequest = DateTimeOffset.Now - lastRequestTime;
if (timeSinceLastRequest < MINIMUM_DELAY_FOR_SAME_HOST)
{
await Task.Delay(MINIMUM_DELAY_FOR_SAME_HOST - timeSinceLastRequest);
}
}
_lastRequestTimes[host] = DateTimeOffset.Now;
}
public async Task<HttpResponseMessage> ProxyRequestAsync(HttpContext context, string targetUri) public async Task<HttpResponseMessage> ProxyRequestAsync(HttpContext context, string targetUri)
{ {
if (!HttpMethods.IsGet(context.Request.Method)) if (!HttpMethods.IsGet(context.Request.Method))
@@ -27,18 +42,6 @@ namespace UmlautAdaptarr.Services
throw new ArgumentException("Only GET requests are supported", context.Request.Method); throw new ArgumentException("Only GET requests are supported", context.Request.Method);
} }
// Throttling mechanism
var host = new Uri(targetUri).Host;
if (_lastRequestTimes.TryGetValue(host, out var lastRequestTime))
{
var timeSinceLastRequest = DateTimeOffset.Now - lastRequestTime;
if (timeSinceLastRequest < TimeSpan.FromSeconds(3))
{
await Task.Delay(TimeSpan.FromSeconds(3) - timeSinceLastRequest);
}
}
_lastRequestTimes[host] = DateTimeOffset.Now;
// Check cache // Check cache
if (_cache.TryGetValue(targetUri, out HttpResponseMessage cachedResponse)) if (_cache.TryGetValue(targetUri, out HttpResponseMessage cachedResponse))
{ {
@@ -46,6 +49,8 @@ namespace UmlautAdaptarr.Services
return cachedResponse!; return cachedResponse!;
} }
await EnsureMinimumDelayAsync(targetUri);
var requestMessage = new HttpRequestMessage var requestMessage = new HttpRequestMessage
{ {
RequestUri = new Uri(targetUri), RequestUri = new Uri(targetUri),

View File

@@ -35,12 +35,14 @@ namespace UmlautAdaptarr.Services
if (_lidarrEnabled) if (_lidarrEnabled)
{ {
fetchedItem = await lidarrClient.FetchItemByExternalIdAsync(externalId); fetchedItem = await lidarrClient.FetchItemByExternalIdAsync(externalId);
fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId);
} }
break; break;
case "book": case "book":
if (_readarrEnabled) if (_readarrEnabled)
{ {
fetchedItem = await readarrClient.FetchItemByExternalIdAsync(externalId); await readarrClient.FetchItemByExternalIdAsync(externalId);
fetchedItem = cacheService.GetSearchItemByExternalId(mediaType, externalId);
} }
break; break;
} }

View File

@@ -13,13 +13,14 @@ namespace UmlautAdaptarr.Services
private async Task EnsureMinimumDelayAsync() private async Task EnsureMinimumDelayAsync()
{ {
var sinceLastRequest = DateTime.Now - lastRequestTime; var sinceLastRequest = DateTime.Now - lastRequestTime;
if (sinceLastRequest < TimeSpan.FromSeconds(2)) if (sinceLastRequest < TimeSpan.FromSeconds(1))
{ {
await Task.Delay(TimeSpan.FromSeconds(2) - sinceLastRequest); await Task.Delay(TimeSpan.FromSeconds(1) - sinceLastRequest);
} }
lastRequestTime = DateTime.Now; lastRequestTime = DateTime.Now;
} }
// TODO add cache, TODO add bulk request
public async Task<(string? germanTitle, string[]? aliases)> FetchGermanTitleAndAliasesByExternalIdAsync(string mediaType, string externalId) public async Task<(string? germanTitle, string[]? aliases)> FetchGermanTitleAndAliasesByExternalIdAsync(string mediaType, string externalId)
{ {
try try

View File

@@ -221,7 +221,7 @@ namespace UmlautAdaptarr.Services
*/ */
// Construct the new title with the original suffix // Construct the new title with the original suffix
var newTitle = newTitlePrefix + (string.IsNullOrEmpty(suffix) ? "" : separator + suffix); var newTitle = newTitlePrefix + (string.IsNullOrEmpty(suffix) ? "" : suffix.StartsWith(separator) ? suffix : $"{separator}{suffix}");
// Update the title element's value with the new title // Update the title element's value with the new title
//titleElement.Value = newTitle + $"({originalTitle.Substring(0, variationLength)})"; //titleElement.Value = newTitle + $"({originalTitle.Substring(0, variationLength)})";
@@ -262,23 +262,23 @@ namespace UmlautAdaptarr.Services
return null; return null;
} }
if (category.StartsWith("EBook", StringComparison.OrdinalIgnoreCase) || category.StartsWith("Book", StringComparison.OrdinalIgnoreCase)) if (category == "7000" || category.StartsWith("EBook", StringComparison.OrdinalIgnoreCase) || category.StartsWith("Book", StringComparison.OrdinalIgnoreCase))
{ {
return "book"; return "book";
} }
else if (category.StartsWith("Movies", StringComparison.OrdinalIgnoreCase)) else if (category == "2000" || category.StartsWith("Movies", StringComparison.OrdinalIgnoreCase))
{ {
return "movies"; return "movies";
} }
else if (category.StartsWith("TV", StringComparison.OrdinalIgnoreCase)) else if (category == "5000" || category.StartsWith("TV", StringComparison.OrdinalIgnoreCase))
{ {
return "tv"; return "tv";
} }
else if (category.Contains("Audiobook", StringComparison.OrdinalIgnoreCase)) else if (category == "3030" || category.Contains("Audiobook", StringComparison.OrdinalIgnoreCase))
{ {
return "book"; return "book";
} }
else if (category.StartsWith("Audio")) else if (category == "3000" || category.StartsWith("Audio"))
{ {
return "audio"; return "audio";
} }

View File

@@ -12,7 +12,7 @@ namespace UmlautAdaptarr.Utilities
// RegEx für eine einfache URL-Validierung ohne http:// und ohne abschließenden Schrägstrich // RegEx für eine einfache URL-Validierung ohne http:// und ohne abschließenden Schrägstrich
// Erlaubt optionale Subdomains, Domainnamen und TLDs, aber keine Pfade oder Protokolle // Erlaubt optionale Subdomains, Domainnamen und TLDs, aber keine Pfade oder Protokolle
var regex = UrlMatchingRegex(); var regex = UrlMatchingRegex();
return regex.IsMatch(domain) && !domain.EndsWith("/"); return regex.IsMatch(domain);
} }
public static string BuildUrl(string domain, IDictionary<string, string> queryParameters) public static string BuildUrl(string domain, IDictionary<string, string> queryParameters)