From 8e8faf66e77abb92883bb9ba3e67ee2f75840dc4 Mon Sep 17 00:00:00 2001 From: Meyn Date: Sat, 18 Jan 2025 19:58:53 +0100 Subject: [PATCH] Implement Youtube only Indexer --- .../Download/Clients/YouTube/YoutubeClient.cs | 2 +- Tubifarry/Indexers/Spotify/SpotifySettings.cs | 34 +-- .../Indexers/Spotify/TubifarryIndexer.cs | 2 +- .../Indexers/Youtube/YoutubeAPISelectors.cs | 54 +++++ Tubifarry/Indexers/Youtube/YoutubeIndexer.cs | 54 +++++ .../Youtube/YoutubeIndexerSettings.cs | 38 +++ Tubifarry/Indexers/Youtube/YoutubeParser.cs | 217 ++++++++++++++++++ .../Youtube/YoutubeRequestGenerator.cs | 106 +++++++++ 8 files changed, 474 insertions(+), 33 deletions(-) create mode 100644 Tubifarry/Indexers/Youtube/YoutubeAPISelectors.cs create mode 100644 Tubifarry/Indexers/Youtube/YoutubeIndexer.cs create mode 100644 Tubifarry/Indexers/Youtube/YoutubeIndexerSettings.cs create mode 100644 Tubifarry/Indexers/Youtube/YoutubeParser.cs create mode 100644 Tubifarry/Indexers/Youtube/YoutubeRequestGenerator.cs diff --git a/Tubifarry/Download/Clients/YouTube/YoutubeClient.cs b/Tubifarry/Download/Clients/YouTube/YoutubeClient.cs index adcf70e..7d8c584 100644 --- a/Tubifarry/Download/Clients/YouTube/YoutubeClient.cs +++ b/Tubifarry/Download/Clients/YouTube/YoutubeClient.cs @@ -51,7 +51,7 @@ public override void RemoveItem(DownloadClientItem item, bool deleteData) protected override void Test(List failures) { _dlManager.SetCookies(Settings.CookiePath); - if (Settings.DownloadPath != null) + if (string.IsNullOrEmpty(Settings.DownloadPath)) failures.AddRange(PermissionTester.TestAllPermissions(Settings.FFmpegPath, _logger)); failures.AddIfNotNull(TestFFmpeg().Result); } diff --git a/Tubifarry/Indexers/Spotify/SpotifySettings.cs b/Tubifarry/Indexers/Spotify/SpotifySettings.cs index 8ff6f2a..5d8a973 100644 --- a/Tubifarry/Indexers/Spotify/SpotifySettings.cs +++ b/Tubifarry/Indexers/Spotify/SpotifySettings.cs @@ -1,36 +1,8 @@ -using FluentValidation; -using NzbDrone.Core.Annotations; -using NzbDrone.Core.Indexers; -using NzbDrone.Core.Validation; -using Tubifarry.Core.Utilities; +using Tubifarry.Indexers.Youtube; namespace Tubifarry.Indexers.Spotify { - public class SpotifyIndexerSettingsValidator : AbstractValidator - { - public SpotifyIndexerSettingsValidator() - { - // Validate CookiePath (if provided) - RuleFor(x => x.CookiePath) - .Must(path => string.IsNullOrEmpty(path) || File.Exists(path)) - .WithMessage("Cookie file does not exist. Please provide a valid path to the cookies file.") - .Must(path => string.IsNullOrEmpty(path) || CookieManager.ParseCookieFile(path).Any()) - .WithMessage("Cookie file is invalid or contains no valid cookies."); - } - } + public class SpotifyIndexerSettingsValidator : YoutubeIndexerSettingsValidator { } - public class SpotifyIndexerSettings : IIndexerSettings - { - private static readonly SpotifyIndexerSettingsValidator Validator = new(); - - [FieldDefinition(0, Type = FieldType.Number, Label = "Early Download Limit", Unit = "days", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Advanced = true)] - public int? EarlyReleaseLimit { get; set; } = null; - - [FieldDefinition(1, Label = "Cookie Path", Type = FieldType.FilePath, Placeholder = "/path/to/cookies.txt", HelpText = "Specify the path to the Spotify cookies file. This is optional but required for accessing restricted content.", Advanced = true)] - public string CookiePath { get; set; } = string.Empty; - - public string BaseUrl { get; set; } = string.Empty; - - public NzbDroneValidationResult Validate() => new(Validator.Validate(this)); - } + public class SpotifyIndexerSettings : YoutubeIndexerSettings { } } \ No newline at end of file diff --git a/Tubifarry/Indexers/Spotify/TubifarryIndexer.cs b/Tubifarry/Indexers/Spotify/TubifarryIndexer.cs index c9f083c..809d685 100644 --- a/Tubifarry/Indexers/Spotify/TubifarryIndexer.cs +++ b/Tubifarry/Indexers/Spotify/TubifarryIndexer.cs @@ -13,7 +13,7 @@ internal class TubifarryIndexer : HttpIndexerBase { public override string Name => "Tubifarry"; public override string Protocol => nameof(YoutubeDownloadProtocol); - public override bool SupportsRss => false; + public override bool SupportsRss => true; public override bool SupportsSearch => true; public override int PageSize => 50; public override TimeSpan RateLimit => new(3); diff --git a/Tubifarry/Indexers/Youtube/YoutubeAPISelectors.cs b/Tubifarry/Indexers/Youtube/YoutubeAPISelectors.cs new file mode 100644 index 0000000..129d1f8 --- /dev/null +++ b/Tubifarry/Indexers/Youtube/YoutubeAPISelectors.cs @@ -0,0 +1,54 @@ +using Newtonsoft.Json.Linq; +using YouTubeMusicAPI.Models; +using YouTubeMusicAPI.Types; + +namespace Tubifarry.Indexers.Youtube +{ + internal static class YoutubeAPISelectors + { + public static T SelectObject(this JToken value, string path) + { + object? result = value.SelectToken(path)?.ToObject(typeof(T)); + return result == null ? throw new ArgumentNullException(path, "Required token is null.") : (T)result; + } + + public static T? SelectObjectOptional(this JToken value, string path) => + (T?)value.SelectToken(path)?.ToObject(typeof(T)); + + public static Radio SelectRadio(this JToken value, string playlistIdPath = "menu.menuRenderer.items[0].menuNavigationItemRenderer.navigationEndpoint.watchEndpoint.playlistId", string? videoIdPath = null) => + new(value.SelectObject(playlistIdPath), videoIdPath == null ? null : value.SelectObjectOptional(videoIdPath)); + + public static Thumbnail[] SelectThumbnails(this JToken value, string path = "thumbnail.musicThumbnailRenderer.thumbnail.thumbnails") + { + JToken? thumbnails = value.SelectToken(path); + if (thumbnails == null) return Array.Empty(); + + return thumbnails + .Select(t => new + { + Url = t.SelectToken("url")?.ToString(), + Width = t.SelectToken("width")?.ToString(), + Height = t.SelectToken("height")?.ToString() + }) + .Where(t => t.Url != null) + .Select(t => new Thumbnail(t.Url!, int.Parse(t.Width ?? "0"), int.Parse(t.Height ?? "0"))) + .ToArray(); + } + + public static YouTubeMusicItem[] SelectArtists(this JToken value, string path, int startIndex = 0, int trimBy = 0) + { + JToken[] runs = value.SelectObject(path); + return runs + .Skip(startIndex) + .Take(runs.Length - trimBy - startIndex) + .Select(run => new + { + Artist = run.SelectToken("text")?.ToString()?.Trim(), + ArtistId = run.SelectToken("navigationEndpoint.browseEndpoint.browseId")?.ToString() + }) + .Where(a => a.Artist != null && a.Artist != "," && a.Artist != "&" && a.Artist != "•") + .Select(a => new YouTubeMusicItem(a.Artist!, a.ArtistId, YouTubeMusicItemKind.Artists)) + .ToArray(); + } + } +} \ No newline at end of file diff --git a/Tubifarry/Indexers/Youtube/YoutubeIndexer.cs b/Tubifarry/Indexers/Youtube/YoutubeIndexer.cs new file mode 100644 index 0000000..96de023 --- /dev/null +++ b/Tubifarry/Indexers/Youtube/YoutubeIndexer.cs @@ -0,0 +1,54 @@ +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser; +using NzbDrone.Core.ThingiProvider; +using Requests; +using Tubifarry.Indexers.Spotify; + +namespace Tubifarry.Indexers.Youtube +{ + internal class YoutubeIndexer : HttpIndexerBase + { + public override string Name => "Youtube"; + public override string Protocol => nameof(YoutubeDownloadProtocol); + public override bool SupportsRss => false; + public override bool SupportsSearch => true; + public override int PageSize => 50; + public override TimeSpan RateLimit => new(30); + + private readonly IYoutubeRequestGenerator _indexerRequestGenerator; + + private readonly IYoutubeParser _parseIndexerResponse; + + public override ProviderMessage Message => new( + "YouTube frequently blocks downloads to prevent unauthorized access. To confirm you're not a bot, you may need to provide additional verification. " + + "This issue can often be partially resolved by using a `cookies.txt` file containing your login tokens. " + + "Ensure the file is properly formatted and includes valid session data to bypass restrictions. " + + "Note: YouTube does not always provide the best metadata for tracks, so you may need to manually verify or update track information.", + ProviderMessageType.Warning + ); + + public YoutubeIndexer(IYoutubeParser parser, IYoutubeRequestGenerator generator, IHttpClient httpClient, IIndexerStatusService indexerStatusService, IConfigService configService, IParsingService parsingService, Logger logger) + : base(httpClient, indexerStatusService, configService, parsingService, logger) + { + _parseIndexerResponse = parser; + _indexerRequestGenerator = generator; + + RequestHandler.MainRequestHandlers[0].MaxParallelism = 2; + } + + protected override Task Test(List failures) + { + _parseIndexerResponse.SetCookies(Settings.CookiePath); + _indexerRequestGenerator.SetCookies(Settings.CookiePath); + return Task.CompletedTask; + } + + public override IIndexerRequestGenerator GetRequestGenerator() => _indexerRequestGenerator; + + public override IParseIndexerResponse GetParser() => _parseIndexerResponse; + } +} \ No newline at end of file diff --git a/Tubifarry/Indexers/Youtube/YoutubeIndexerSettings.cs b/Tubifarry/Indexers/Youtube/YoutubeIndexerSettings.cs new file mode 100644 index 0000000..1a2c321 --- /dev/null +++ b/Tubifarry/Indexers/Youtube/YoutubeIndexerSettings.cs @@ -0,0 +1,38 @@ + +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Validation; +using Tubifarry.Core.Utilities; + +namespace Tubifarry.Indexers.Youtube +{ + + public class YoutubeIndexerSettingsValidator : AbstractValidator + { + public YoutubeIndexerSettingsValidator() + { + // Validate CookiePath (if provided) + RuleFor(x => x.CookiePath) + .Must(path => string.IsNullOrEmpty(path) || File.Exists(path)) + .WithMessage("Cookie file does not exist. Please provide a valid path to the cookies file.") + .Must(path => string.IsNullOrEmpty(path) || CookieManager.ParseCookieFile(path).Any()) + .WithMessage("Cookie file is invalid or contains no valid cookies."); + } + } + + public class YoutubeIndexerSettings : IIndexerSettings + { + private static readonly YoutubeIndexerSettingsValidator Validator = new(); + + [FieldDefinition(0, Type = FieldType.Number, Label = "Early Download Limit", Unit = "days", HelpText = "Time before release date Lidarr will download from this indexer, empty is no limit", Advanced = true)] + public int? EarlyReleaseLimit { get; set; } = null; + + [FieldDefinition(1, Label = "Cookie Path", Type = FieldType.FilePath, Placeholder = "/path/to/cookies.txt", HelpText = "Specify the path to the Spotify cookies file. This is optional but required for accessing restricted content.", Advanced = true)] + public string CookiePath { get; set; } = string.Empty; + + public string BaseUrl { get; set; } = string.Empty; + + public NzbDroneValidationResult Validate() => new(Validator.Validate(this)); + } +} diff --git a/Tubifarry/Indexers/Youtube/YoutubeParser.cs b/Tubifarry/Indexers/Youtube/YoutubeParser.cs new file mode 100644 index 0000000..3ebaaf7 --- /dev/null +++ b/Tubifarry/Indexers/Youtube/YoutubeParser.cs @@ -0,0 +1,217 @@ +using Newtonsoft.Json.Linq; +using NLog; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.Parser.Model; +using Tubifarry.Core.Model; +using Tubifarry.Core.Utilities; +using YouTubeMusicAPI.Client; +using YouTubeMusicAPI.Models; +using YouTubeMusicAPI.Models.Search; +using YouTubeMusicAPI.Models.Streaming; +using YouTubeMusicAPI.Types; + +namespace Tubifarry.Indexers.Youtube +{ + + public interface IYoutubeParser : IParseIndexerResponse + { + public void SetCookies(string path); + } + + /// + /// Parses Spotify responses and converts them to YouTube Music releases. + /// + public class YoutubeParser : IYoutubeParser + { + private YouTubeMusicClient _ytClient; + + private readonly Logger _logger; + private string? _cookiePath; + + public YoutubeParser(Logger logger) + { + _logger = logger; + _ytClient = new YouTubeMusicClient(); + } + + public void SetCookies(string path) + { + if (string.IsNullOrEmpty(path) || path == _cookiePath) + return; + _cookiePath = path; + _ytClient = new(cookies: CookieManager.ParseCookieFile(path)); + } + + public IList ParseResponse(IndexerResponse indexerResponse) + { + List releases = new(); + _logger.Trace("Starting to parse Spotify response."); + + try + { + IEnumerable albums = ParseSearchResponse(JObject.Parse(indexerResponse.Content)).Where(searchResult => searchResult.Kind == YouTubeMusicItemKind.Albums).SelectMany(x => x.Items.Cast()); + + ProcessAlbumsAsync(albums, releases).Wait(); + return releases.DistinctBy(x => x.DownloadUrl).OrderByDescending(o => o.PublishDate).ToArray(); + } + catch (Exception ex) + { + _logger.Error(ex, $"An error occurred while parsing the Spotify response. Response content: {indexerResponse.Content}"); + } + return releases.DistinctBy(x => x.DownloadUrl).OrderByDescending(o => o.PublishDate).ToArray(); + } + IEnumerable ParseSearchResponse(JObject requestResponse) + { + List results = new(); + + bool isContinued = requestResponse.ContainsKey("continuationContents"); + + IEnumerable? shelvesData = isContinued + ? requestResponse.SelectToken("continuationContents") + : requestResponse + .SelectToken("contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents") + ?.Where(token => token["musicShelfRenderer"] is not null) + ?.Select(token => token.First!); + + if (shelvesData is null || !shelvesData.Any()) + { + _logger?.Warn($"Parsing search failed. Request response does not contain any shelves."); + return new List(); + } + + foreach (JToken? shelfData in shelvesData) + { + JToken? shelfDataObject = shelfData.First; + if (shelfDataObject is null) + continue; + + string? nextContinuationToken = shelfDataObject.SelectObjectOptional("continuations[0].nextContinuationData.continuation"); + + string? category = isContinued + ? requestResponse + .SelectToken("header.musicHeaderRenderer.header.chipCloudRenderer.chips") + ?.FirstOrDefault(token => token.SelectObjectOptional("chipCloudChipRenderer.isSelected")) + ?.SelectObjectOptional("chipCloudChipRenderer.uniqueId") + : shelfDataObject.SelectObjectOptional("title.runs[0].text"); + + JToken[] shelfItems = shelfDataObject.SelectObjectOptional("contents") ?? Array.Empty(); + + YouTubeMusicItemKind kind = category.ToShelfKind(); + Func? getShelfItem = kind switch + { + YouTubeMusicItemKind.Albums => GetAlbums, + _ => null + }; + + List items = new(); + if (getShelfItem is not null) + { + foreach (JToken shelfItem in shelfItems) + { + JToken? itemObject = shelfItem.First?.First; + if (itemObject is null) + continue; + + items.Add(getShelfItem(itemObject)); + } + } + + Shelf shelf = new(nextContinuationToken, items.ToArray(), kind); + results.Add(shelf); + } + + return results; + } + + public static AlbumSearchResult GetAlbums(JToken jsonToken) + { + YouTubeMusicItem[] artists = jsonToken.SelectArtists("flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs", 2, 1); + int yearIndex = artists[0].Id is null ? 4 : artists.Length * 2 + 2; + + return new( + name: jsonToken.SelectObject("flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text"), + id: jsonToken.SelectObject("overlay.musicItemThumbnailOverlayRenderer.content.musicPlayButtonRenderer.playNavigationEndpoint.watchPlaylistEndpoint.playlistId"), + artists: artists, + releaseYear: jsonToken.SelectObject($"flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[{yearIndex}].text"), + isSingle: jsonToken.SelectObject("flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text") == "Single", + radio: jsonToken.SelectRadio("menu.menuRenderer.items[1].menuNavigationItemRenderer.navigationEndpoint.watchPlaylistEndpoint.playlistId", null), + thumbnails: jsonToken.SelectThumbnails() + ); + } + + private async Task AddYoutubeData(AlbumData albumData) + { + try + { + string browseId = await _ytClient.GetAlbumBrowseIdAsync(albumData.AlbumId); + YouTubeMusicAPI.Models.Info.AlbumInfo album = await _ytClient.GetAlbumInfoAsync(browseId); + + if (album?.Songs == null || !album.Songs.Any()) return; + + YouTubeMusicAPI.Models.Info.AlbumSongInfo? firstTrack = album.Songs.FirstOrDefault(); + if (firstTrack?.Id == null) return; + + try + { + StreamingData streamingData = await _ytClient.GetStreamingDataAsync(firstTrack.Id); + AudioStreamInfo? highestAudioStreamInfo = streamingData.StreamInfo.OfType().OrderByDescending(info => info.Bitrate).FirstOrDefault(); + + if (highestAudioStreamInfo != null) + { + albumData.Duration = (long)album.Duration.TotalSeconds; + albumData.Bitrate = AudioFormatHelper.RoundToStandardBitrate(highestAudioStreamInfo.Bitrate / 1000); + albumData.TotalTracks = album.SongCount; + albumData.ExplicitContent = album.Songs.Any(x => x.IsExplicit); + _logger.Debug($"Successfully added YouTube data for album: {albumData.AlbumName}."); + } + } + catch (Exception ex) + { + _logger.Error(ex, $"Failed to process track {firstTrack.Name} in album {albumData.AlbumName}."); + } + + } + catch (Exception ex) + { + _logger.Error(ex, $"Unexpected error while adding YouTube data for album: {albumData.AlbumName}."); + } + } + + private async Task ProcessAlbumsAsync(IEnumerable searchResult, List releases) + { + int i = 0; + foreach (AlbumSearchResult album in searchResult) + { + if (i >= 10) + break; + try + { + AlbumData albumInfo = ExtractAlbumInfo(album); + albumInfo.ParseReleaseDate(); + await AddYoutubeData(albumInfo); + + if (albumInfo.Bitrate == 0) + _logger.Trace($"No YouTube Music URL found for album: {albumInfo.AlbumName} by {albumInfo.ArtistName}."); + else + releases.Add(albumInfo.ToReleaseInfo()); + } + catch (Exception ex) + { + _logger.Error(ex, $"An error occurred while processing an album: {ex.Message}. Album JSON: {album}"); + } + i++; + } + } + + private static AlbumData ExtractAlbumInfo(AlbumSearchResult album) => new("Youtube") + { + AlbumId = album.Id, + AlbumName = album.Name, + ArtistName = album.Artists.FirstOrDefault()?.Name ?? "UnknownArtist", + ReleaseDate = album.ReleaseYear.ToString() ?? "0000-01-01", + ReleaseDatePrecision = "year", + CustomString = album.Thumbnails.FirstOrDefault()?.Url ?? string.Empty, + CoverResolution = (album.Thumbnails.FirstOrDefault() is Thumbnail thumbnail) ? $"{thumbnail.Width}x{thumbnail.Height}" : "UnknownResolution" + }; + } +} diff --git a/Tubifarry/Indexers/Youtube/YoutubeRequestGenerator.cs b/Tubifarry/Indexers/Youtube/YoutubeRequestGenerator.cs new file mode 100644 index 0000000..99a98d2 --- /dev/null +++ b/Tubifarry/Indexers/Youtube/YoutubeRequestGenerator.cs @@ -0,0 +1,106 @@ +using Newtonsoft.Json; +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Indexers; +using NzbDrone.Core.IndexerSearch.Definitions; +using Tubifarry.Core.Utilities; +using YouTubeMusicAPI.Internal; +using YouTubeMusicAPI.Types; + +namespace Tubifarry.Indexers.Youtube +{ + public interface IYoutubeRequestGenerator : IIndexerRequestGenerator + { + public void SetCookies(string path); + } + + internal class YoutubeRequestGenerator : IYoutubeRequestGenerator + { + private const int MaxPages = 3; + + private readonly Logger _logger; + private string? _cookiePath; + + public YoutubeRequestGenerator(Logger logger) => _logger = logger; + + + public IndexerPageableRequestChain GetRecentRequests() + { + IndexerPageableRequestChain pageableRequests = new(); + //pageableRequests.Add(GetRecentReleaseRequests()); + return pageableRequests; + } + + private IEnumerable GetRecentReleaseRequests() + { + Dictionary payload = Payload.Web( + geographicalLocation: "US", + items: new (string key, object? value)[] + { + ("browseId", "FEmusic_new_releases"), + ("params", Extensions.ToParams(YouTubeMusicItemKind.Albums)) + } + ); + + string jsonPayload = JsonConvert.SerializeObject(payload, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); + + string url = "https://music.youtube.com/youtubei/v1/browse"; + HttpRequest request = new(url, HttpAccept.Json) { Method = HttpMethod.Post }; + request.SetContent(jsonPayload); + + _logger.Trace($"Created request for recent releases: {url}"); + + IndexerRequest req = new(request); + yield return req; + } + + public void SetCookies(string path) + { + if (string.IsNullOrEmpty(path) || path == _cookiePath) + return; + _cookiePath = path; + } + + public IndexerPageableRequestChain GetSearchRequests(AlbumSearchCriteria searchCriteria) + { + _logger.Debug($"Generating search requests for album: {searchCriteria.AlbumQuery} by artist: {searchCriteria.ArtistQuery}"); + IndexerPageableRequestChain chain = new(); + + string searchQuery = $"album:{searchCriteria.AlbumQuery} artist:{searchCriteria.ArtistQuery}"; + for (int page = 0; page < MaxPages; page++) + chain.AddTier(GetRequests(searchQuery, YouTubeMusicItemKind.Albums)); + return chain; + } + + public IndexerPageableRequestChain GetSearchRequests(ArtistSearchCriteria searchCriteria) + { + _logger.Debug($"Generating search requests for artist: {searchCriteria.ArtistQuery}"); + IndexerPageableRequestChain chain = new(); + + string searchQuery = $"artist:{searchCriteria.ArtistQuery}"; + for (int page = 0; page < MaxPages; page++) + chain.AddTier(GetRequests(searchQuery, YouTubeMusicItemKind.Albums)); + return chain; + } + + private IEnumerable GetRequests(string searchQuery, YouTubeMusicItemKind kind) + { + Dictionary payload = Payload.Web("US", new (string key, object? value)[] { ("query", searchQuery), ("params", Extensions.ToParams(kind)), ("continuation", null) }); + + string jsonPayload = JsonConvert.SerializeObject(payload, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); + + string url = $"https://music.youtube.com/youtubei/v1/search"; + HttpRequest s = new(url, HttpAccept.Json); + if (!string.IsNullOrEmpty(_cookiePath)) + foreach (System.Net.Cookie cookie in CookieManager.ParseCookieFile(_cookiePath)) + if (s.Cookies.ContainsKey(cookie.Name)) + s.Cookies[cookie.Name] = cookie.Value; + else + s.Cookies.Add(cookie.Name, cookie.Value); + s.Method = HttpMethod.Post; + s.SetContent(jsonPayload); + IndexerRequest req = new(s); + yield return req; + } + } +}