diff options
Diffstat (limited to 'Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs')
| -rw-r--r-- | Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs | 1014 |
1 files changed, 302 insertions, 712 deletions
diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index 0bbffb824..1f963e4a2 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -1,44 +1,61 @@ +#nullable disable + +#pragma warning disable CS1591 + using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; -using System.IO; using System.Linq; using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Mime; +using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common; +using Emby.Server.Implementations.LiveTv.Listings.SchedulesDirectDtos; +using Jellyfin.Extensions; +using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; using MediaBrowser.Controller.LiveTv; +using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.Extensions; using MediaBrowser.Model.LiveTv; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.Listings { public class SchedulesDirect : IListingsProvider { - private readonly ILogger _logger; - private readonly IJsonSerializer _jsonSerializer; - private readonly IHttpClient _httpClient; + private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; + + private readonly ILogger<SchedulesDirect> _logger; + private readonly IHttpClientFactory _httpClientFactory; private readonly SemaphoreSlim _tokenSemaphore = new SemaphoreSlim(1, 1); - private readonly IApplicationHost _appHost; + private readonly ICryptoProvider _cryptoProvider; - private const string ApiUrl = "https://json.schedulesdirect.org/20141201"; + private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); + private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; + private DateTime _lastErrorResponse; - public SchedulesDirect(ILogger logger, IJsonSerializer jsonSerializer, IHttpClient httpClient, IApplicationHost appHost) + public SchedulesDirect( + ILogger<SchedulesDirect> logger, + IHttpClientFactory httpClientFactory, + ICryptoProvider cryptoProvider) { _logger = logger; - _jsonSerializer = jsonSerializer; - _httpClient = httpClient; - _appHost = appHost; + _httpClientFactory = httpClientFactory; + _cryptoProvider = cryptoProvider; } - private string UserAgent => _appHost.ApplicationUserAgent; + /// <inheritdoc /> + public string Name => "Schedules Direct"; + + /// <inheritdoc /> + public string Type => nameof(SchedulesDirect); private static List<string> GetScheduleRequestDates(DateTime startDateUtc, DateTime endDateUtc) { @@ -49,7 +66,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings while (start <= end) { - dates.Add(start.ToString("yyyy-MM-dd")); + dates.Add(start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); start = start.AddDays(1); } @@ -78,173 +95,170 @@ namespace Emby.Server.Implementations.LiveTv.Listings var dates = GetScheduleRequestDates(startDateUtc, endDateUtc); _logger.LogInformation("Channel Station ID is: {ChannelID}", channelId); - var requestList = new List<ScheduleDirect.RequestScheduleForChannel>() + var requestList = new List<RequestScheduleForChannelDto>() { - new ScheduleDirect.RequestScheduleForChannel() + new RequestScheduleForChannelDto() { - stationID = channelId, - date = dates + StationId = channelId, + Date = dates } }; - var requestString = _jsonSerializer.SerializeToString(requestList); + var requestString = JsonSerializer.Serialize(requestList, _jsonOptions); _logger.LogDebug("Request string for schedules is: {RequestString}", requestString); - var httpOptions = new HttpRequestOptions() + using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules"); + options.Content = new StringContent(requestString, Encoding.UTF8, MediaTypeNames.Application.Json); + options.Headers.TryAddWithoutValidation("token", token); + using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false); + await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var dailySchedules = await JsonSerializer.DeserializeAsync<IReadOnlyList<DayDto>>(responseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (dailySchedules == null) { - Url = ApiUrl + "/schedules", - UserAgent = UserAgent, - CancellationToken = cancellationToken, - // The data can be large so give it some extra time - TimeoutMs = 60000, - LogErrorResponseBody = true, - RequestContent = requestString - }; - - httpOptions.RequestHeaders["token"] = token; - - using (var response = await Post(httpOptions, true, info).ConfigureAwait(false)) - using (var reader = new StreamReader(response.Content)) - { - var dailySchedules = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Day>>(response.Content).ConfigureAwait(false); - _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); + return Array.Empty<ProgramInfo>(); + } - httpOptions = new HttpRequestOptions() - { - Url = ApiUrl + "/programs", - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true, - // The data can be large so give it some extra time - TimeoutMs = 60000 - }; + _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, channelId); - httpOptions.RequestHeaders["token"] = token; + using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs"); + programRequestOptions.Headers.TryAddWithoutValidation("token", token); - var programsID = dailySchedules.SelectMany(d => d.programs.Select(s => s.programID)).Distinct(); - httpOptions.RequestContent = "[\"" + string.Join("\", \"", programsID) + "\"]"; + var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct(); + programRequestOptions.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(programIds, _jsonOptions)); + programRequestOptions.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(MediaTypeNames.Application.Json); - using (var innerResponse = await Post(httpOptions, true, info).ConfigureAwait(false)) - using (var innerReader = new StreamReader(innerResponse.Content)) - { - var programDetails = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ProgramDetails>>(innerResponse.Content).ConfigureAwait(false); - var programDict = programDetails.ToDictionary(p => p.programID, y => y); + using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); + await using var innerResponseStream = await innerResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var programDetails = await JsonSerializer.DeserializeAsync<IReadOnlyList<ProgramDetailsDto>>(innerResponseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (programDetails == null) + { + return Array.Empty<ProgramInfo>(); + } - var programIdsWithImages = - programDetails.Where(p => p.hasImageArtwork).Select(p => p.programID) - .ToList(); + var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y); - var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); + var programIdsWithImages = programDetails + .Where(p => p.HasImageArtwork).Select(p => p.ProgramId) + .ToList(); - var programsInfo = new List<ProgramInfo>(); - foreach (ScheduleDirect.Program schedule in dailySchedules.SelectMany(d => d.programs)) - { - //_logger.LogDebug("Proccesing Schedule for statio ID " + stationID + - // " which corresponds to channel " + channelNumber + " and program id " + - // schedule.programID + " which says it has images? " + - // programDict[schedule.programID].hasImageArtwork); + var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false); - if (images != null) - { - var imageIndex = images.FindIndex(i => i.programID == schedule.programID.Substring(0, 10)); - if (imageIndex > -1) - { - var programEntry = programDict[schedule.programID]; + var programsInfo = new List<ProgramInfo>(); + foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs)) + { + // _logger.LogDebug("Proccesing Schedule for statio ID " + stationID + + // " which corresponds to channel " + channelNumber + " and program id " + + // schedule.ProgramId + " which says it has images? " + + // programDict[schedule.ProgramId].hasImageArtwork); - var allImages = images[imageIndex].data ?? new List<ScheduleDirect.ImageData>(); - var imagesWithText = allImages.Where(i => string.Equals(i.text, "yes", StringComparison.OrdinalIgnoreCase)); - var imagesWithoutText = allImages.Where(i => string.Equals(i.text, "no", StringComparison.OrdinalIgnoreCase)); + if (string.IsNullOrEmpty(schedule.ProgramId)) + { + continue; + } - const double desiredAspect = 0.666666667; + if (images != null) + { + var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]); + if (imageIndex > -1) + { + var programEntry = programDict[schedule.ProgramId]; - programEntry.primaryImage = GetProgramImage(ApiUrl, imagesWithText, true, desiredAspect) ?? - GetProgramImage(ApiUrl, allImages, true, desiredAspect); + var allImages = images[imageIndex].Data; + var imagesWithText = allImages.Where(i => string.Equals(i.Text, "yes", StringComparison.OrdinalIgnoreCase)); + var imagesWithoutText = allImages.Where(i => string.Equals(i.Text, "no", StringComparison.OrdinalIgnoreCase)); - const double wideAspect = 1.77777778; + const double DesiredAspect = 2.0 / 3; - programEntry.thumbImage = GetProgramImage(ApiUrl, imagesWithText, true, wideAspect); + programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, true, DesiredAspect) ?? + GetProgramImage(ApiUrl, allImages, true, DesiredAspect); - // Don't supply the same image twice - if (string.Equals(programEntry.primaryImage, programEntry.thumbImage, StringComparison.Ordinal)) - { - programEntry.thumbImage = null; - } + const double WideAspect = 16.0 / 9; - programEntry.backdropImage = GetProgramImage(ApiUrl, imagesWithoutText, true, wideAspect); + programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, true, WideAspect); - //programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ?? - // GetProgramImage(ApiUrl, data, "Banner-L1", false) ?? - // GetProgramImage(ApiUrl, data, "Banner-LO", false) ?? - // GetProgramImage(ApiUrl, data, "Banner-LOT", false); - } + // Don't supply the same image twice + if (string.Equals(programEntry.PrimaryImage, programEntry.ThumbImage, StringComparison.Ordinal)) + { + programEntry.ThumbImage = null; } - programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.programID])); + programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, true, WideAspect); + + // programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-L1", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-LO", false) ?? + // GetProgramImage(ApiUrl, data, "Banner-LOT", false); } - return programsInfo; } + + programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.ProgramId])); } + + return programsInfo; } - private static int GetSizeOrder(ScheduleDirect.ImageData image) + private static int GetSizeOrder(ImageDataDto image) { - if (!string.IsNullOrWhiteSpace(image.height)) + if (int.TryParse(image.Height, out int value)) { - if (int.TryParse(image.height, out int value)) - { - return value; - } + return value; } return 0; } - private static string GetChannelNumber(ScheduleDirect.Map map) + private static string GetChannelNumber(MapDto map) { - var channelNumber = map.logicalChannelNumber; + var channelNumber = map.LogicalChannelNumber; if (string.IsNullOrWhiteSpace(channelNumber)) { - channelNumber = map.channel; + channelNumber = map.Channel; } + if (string.IsNullOrWhiteSpace(channelNumber)) { - channelNumber = map.atscMajor + "." + map.atscMinor; + channelNumber = map.AtscMajor + "." + map.AtscMinor; } return channelNumber.TrimStart('0'); } - private static bool IsMovie(ScheduleDirect.ProgramDetails programInfo) + private static bool IsMovie(ProgramDetailsDto programInfo) { - return string.Equals(programInfo.entityType, "movie", StringComparison.OrdinalIgnoreCase); + return string.Equals(programInfo.EntityType, "movie", StringComparison.OrdinalIgnoreCase); } - private ProgramInfo GetProgram(string channelId, ScheduleDirect.Program programInfo, ScheduleDirect.ProgramDetails details) + private ProgramInfo GetProgram(string channelId, ProgramDto programInfo, ProgramDetailsDto details) { - var startAt = GetDate(programInfo.airDateTime); - var endAt = startAt.AddSeconds(programInfo.duration); + if (programInfo.AirDateTime == null) + { + return null; + } + + var startAt = programInfo.AirDateTime.Value; + var endAt = startAt.AddSeconds(programInfo.Duration); var audioType = ProgramAudio.Stereo; - var programId = programInfo.programID ?? string.Empty; + var programId = programInfo.ProgramId ?? string.Empty; string newID = programId + "T" + startAt.Ticks + "C" + channelId; - if (programInfo.audioProperties != null) + if (programInfo.AudioProperties.Count != 0) { - if (programInfo.audioProperties.Exists(item => string.Equals(item, "atmos", StringComparison.OrdinalIgnoreCase))) + if (programInfo.AudioProperties.Contains("atmos", StringComparer.OrdinalIgnoreCase)) { audioType = ProgramAudio.Atmos; } - else if (programInfo.audioProperties.Exists(item => string.Equals(item, "dd 5.1", StringComparison.OrdinalIgnoreCase))) + else if (programInfo.AudioProperties.Contains("dd 5.1", StringComparer.OrdinalIgnoreCase)) { audioType = ProgramAudio.DolbyDigital; } - else if (programInfo.audioProperties.Exists(item => string.Equals(item, "dd", StringComparison.OrdinalIgnoreCase))) + else if (programInfo.AudioProperties.Contains("dd", StringComparer.OrdinalIgnoreCase)) { audioType = ProgramAudio.DolbyDigital; } - else if (programInfo.audioProperties.Exists(item => string.Equals(item, "stereo", StringComparison.OrdinalIgnoreCase))) + else if (programInfo.AudioProperties.Contains("stereo", StringComparer.OrdinalIgnoreCase)) { audioType = ProgramAudio.Stereo; } @@ -255,9 +269,9 @@ namespace Emby.Server.Implementations.LiveTv.Listings } string episodeTitle = null; - if (details.episodeTitle150 != null) + if (details.EpisodeTitle150 != null) { - episodeTitle = details.episodeTitle150; + episodeTitle = details.EpisodeTitle150; } var info = new ProgramInfo @@ -266,22 +280,22 @@ namespace Emby.Server.Implementations.LiveTv.Listings Id = newID, StartDate = startAt, EndDate = endAt, - Name = details.titles[0].title120 ?? "Unkown", + Name = details.Titles[0].Title120 ?? "Unknown", OfficialRating = null, CommunityRating = null, EpisodeTitle = episodeTitle, Audio = audioType, - //IsNew = programInfo.@new ?? false, - IsRepeat = programInfo.@new == null, - IsSeries = string.Equals(details.entityType, "episode", StringComparison.OrdinalIgnoreCase), - ImageUrl = details.primaryImage, - ThumbImageUrl = details.thumbImage, - IsKids = string.Equals(details.audience, "children", StringComparison.OrdinalIgnoreCase), - IsSports = string.Equals(details.entityType, "sports", StringComparison.OrdinalIgnoreCase), + // IsNew = programInfo.@new ?? false, + IsRepeat = programInfo.New == null, + IsSeries = string.Equals(details.EntityType, "episode", StringComparison.OrdinalIgnoreCase), + ImageUrl = details.PrimaryImage, + ThumbImageUrl = details.ThumbImage, + IsKids = string.Equals(details.Audience, "children", StringComparison.OrdinalIgnoreCase), + IsSports = string.Equals(details.EntityType, "sports", StringComparison.OrdinalIgnoreCase), IsMovie = IsMovie(details), - Etag = programInfo.md5, - IsLive = string.Equals(programInfo.liveTapeDelay, "live", StringComparison.OrdinalIgnoreCase), - IsPremiere = programInfo.premiere || (programInfo.isPremiereOrFinale ?? string.Empty).IndexOf("premiere", StringComparison.OrdinalIgnoreCase) != -1 + Etag = programInfo.Md5, + IsLive = string.Equals(programInfo.LiveTapeDelay, "live", StringComparison.OrdinalIgnoreCase), + IsPremiere = programInfo.Premiere || (programInfo.IsPremiereOrFinale ?? string.Empty).IndexOf("premiere", StringComparison.OrdinalIgnoreCase) != -1 }; var showId = programId; @@ -304,15 +318,16 @@ namespace Emby.Server.Implementations.LiveTv.Listings info.ShowId = showId; - if (programInfo.videoProperties != null) + if (programInfo.VideoProperties != null) { - info.IsHD = programInfo.videoProperties.Contains("hdtv", StringComparer.OrdinalIgnoreCase); - info.Is3D = programInfo.videoProperties.Contains("3d", StringComparer.OrdinalIgnoreCase); + info.IsHD = programInfo.VideoProperties.Contains("hdtv", StringComparer.OrdinalIgnoreCase); + info.Is3D = programInfo.VideoProperties.Contains("3d", StringComparer.OrdinalIgnoreCase); } - if (details.contentRating != null && details.contentRating.Count > 0) + if (details.ContentRating != null && details.ContentRating.Count > 0) { - info.OfficialRating = details.contentRating[0].code.Replace("TV", "TV-").Replace("--", "-"); + info.OfficialRating = details.ContentRating[0].Code.Replace("TV", "TV-", StringComparison.Ordinal) + .Replace("--", "-", StringComparison.Ordinal); var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" }; if (invalid.Contains(info.OfficialRating, StringComparer.OrdinalIgnoreCase)) @@ -321,15 +336,15 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - if (details.descriptions != null) + if (details.Descriptions != null) { - if (details.descriptions.description1000 != null && details.descriptions.description1000.Count > 0) + if (details.Descriptions.Description1000 != null && details.Descriptions.Description1000.Count > 0) { - info.Overview = details.descriptions.description1000[0].description; + info.Overview = details.Descriptions.Description1000[0].Description; } - else if (details.descriptions.description100 != null && details.descriptions.description100.Count > 0) + else if (details.Descriptions.Description100 != null && details.Descriptions.Description100.Count > 0) { - info.Overview = details.descriptions.description100[0].description; + info.Overview = details.Descriptions.Description100[0].Description; } } @@ -337,20 +352,20 @@ namespace Emby.Server.Implementations.LiveTv.Listings { info.SeriesId = programId.Substring(0, 10); - info.SeriesProviderIds[MetadataProviders.Zap2It.ToString()] = info.SeriesId; + info.SeriesProviderIds[MetadataProvider.Zap2It.ToString()] = info.SeriesId; - if (details.metadata != null) + if (details.Metadata != null) { - foreach (var metadataProgram in details.metadata) + foreach (var metadataProgram in details.Metadata) { var gracenote = metadataProgram.Gracenote; if (gracenote != null) { - info.SeasonNumber = gracenote.season; + info.SeasonNumber = gracenote.Season; - if (gracenote.episode > 0) + if (gracenote.Episode > 0) { - info.EpisodeNumber = gracenote.episode; + info.EpisodeNumber = gracenote.Episode; } break; @@ -359,24 +374,25 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - if (!string.IsNullOrWhiteSpace(details.originalAirDate)) + if (details.OriginalAirDate != null) { - info.OriginalAirDate = DateTime.Parse(details.originalAirDate); + info.OriginalAirDate = details.OriginalAirDate; info.ProductionYear = info.OriginalAirDate.Value.Year; } - if (details.movie != null) + if (details.Movie != null) { - if (!string.IsNullOrEmpty(details.movie.year) && int.TryParse(details.movie.year, out int year)) + if (!string.IsNullOrEmpty(details.Movie.Year) + && int.TryParse(details.Movie.Year, out int year)) { info.ProductionYear = year; } } - if (details.genres != null) + if (details.Genres != null) { - info.Genres = details.genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(); - info.IsNews = details.genres.Contains("news", StringComparer.OrdinalIgnoreCase); + info.Genres = details.Genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList(); + info.IsNews = details.Genres.Contains("news", StringComparer.OrdinalIgnoreCase); if (info.Genres.Contains("children", StringComparer.OrdinalIgnoreCase)) { @@ -387,22 +403,11 @@ namespace Emby.Server.Implementations.LiveTv.Listings return info; } - private static DateTime GetDate(string value) - { - var date = DateTime.ParseExact(value, "yyyy'-'MM'-'dd'T'HH':'mm':'ss'Z'", CultureInfo.InvariantCulture); - - if (date.Kind != DateTimeKind.Utc) - { - date = DateTime.SpecifyKind(date, DateTimeKind.Utc); - } - return date; - } - - private string GetProgramImage(string apiUrl, IEnumerable<ScheduleDirect.ImageData> images, bool returnDefaultImage, double desiredAspect) + private string GetProgramImage(string apiUrl, IEnumerable<ImageDataDto> images, bool returnDefaultImage, double desiredAspect) { var match = images .OrderBy(i => Math.Abs(desiredAspect - GetAspectRatio(i))) - .ThenByDescending(GetSizeOrder) + .ThenByDescending(i => GetSizeOrder(i)) .FirstOrDefault(); if (match == null) @@ -410,7 +415,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings return null; } - var uri = match.uri; + var uri = match.Uri; if (string.IsNullOrWhiteSpace(uri)) { @@ -426,19 +431,19 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - private static double GetAspectRatio(ScheduleDirect.ImageData i) + private static double GetAspectRatio(ImageDataDto i) { int width = 0; int height = 0; - if (!string.IsNullOrWhiteSpace(i.width)) + if (!string.IsNullOrWhiteSpace(i.Width)) { - int.TryParse(i.width, out width); + _ = int.TryParse(i.Width, out width); } - if (!string.IsNullOrWhiteSpace(i.height)) + if (!string.IsNullOrWhiteSpace(i.Height)) { - int.TryParse(i.height, out height); + _ = int.TryParse(i.Height, out height); } if (height == 0 || width == 0) @@ -451,60 +456,50 @@ namespace Emby.Server.Implementations.LiveTv.Listings return result; } - private async Task<List<ScheduleDirect.ShowImages>> GetImageForPrograms( + private async Task<IReadOnlyList<ShowImagesDto>> GetImageForPrograms( ListingsProviderInfo info, - List<string> programIds, - CancellationToken cancellationToken) + IReadOnlyList<string> programIds, + CancellationToken cancellationToken) { if (programIds.Count == 0) { - return new List<ScheduleDirect.ShowImages>(); + return Array.Empty<ShowImagesDto>(); } - var imageIdString = "["; - - foreach (var i in programIds) + StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13)); + foreach (ReadOnlySpan<char> i in programIds) { - var imageId = i.Substring(0, 10); - - if (!imageIdString.Contains(imageId)) - { - imageIdString += "\"" + imageId + "\","; - } + str.Append('"') + .Append(i.Slice(0, 10)) + .Append("\","); } - imageIdString = imageIdString.TrimEnd(',') + "]"; + // Remove last , + str.Length--; + str.Append(']'); - var httpOptions = new HttpRequestOptions() + using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs") { - Url = ApiUrl + "/metadata/programs", - UserAgent = UserAgent, - CancellationToken = cancellationToken, - RequestContent = imageIdString, - LogErrorResponseBody = true, - // The data can be large so give it some extra time - TimeoutMs = 60000 + Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json) }; try { - using (var innerResponse2 = await Post(httpOptions, true, info).ConfigureAwait(false)) - { - return await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.ShowImages>>( - innerResponse2.Content).ConfigureAwait(false); - } + using var innerResponse2 = await Send(message, true, info, cancellationToken).ConfigureAwait(false); + await using var response = await innerResponse2.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync<IReadOnlyList<ShowImagesDto>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "Error getting image info from schedules direct"); - return new List<ScheduleDirect.ShowImages>(); + return Array.Empty<ShowImagesDto>(); } } public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, CancellationToken cancellationToken) { - var token = await GetToken(info, cancellationToken); + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); var lineups = new List<NameIdPair>(); @@ -513,41 +508,33 @@ namespace Emby.Server.Implementations.LiveTv.Listings return lineups; } - var options = new HttpRequestOptions() - { - Url = ApiUrl + "/headends?country=" + country + "&postalcode=" + location, - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true - }; - - options.RequestHeaders["token"] = token; + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&postalcode=" + location); + options.Headers.TryAddWithoutValidation("token", token); try { - using (var httpResponse = await Get(options, false, info).ConfigureAwait(false)) - using (Stream responce = httpResponse.Content) - { - var root = await _jsonSerializer.DeserializeFromStreamAsync<List<ScheduleDirect.Headends>>(responce).ConfigureAwait(false); + using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false); + await using var response = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + + var root = await JsonSerializer.DeserializeAsync<IReadOnlyList<HeadendsDto>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (root != null) + if (root != null) + { + foreach (HeadendsDto headend in root) { - foreach (ScheduleDirect.Headends headend in root) + foreach (LineupDto lineup in headend.Lineups) { - foreach (ScheduleDirect.Lineup lineup in headend.lineups) + lineups.Add(new NameIdPair { - lineups.Add(new NameIdPair - { - Name = string.IsNullOrWhiteSpace(lineup.name) ? lineup.lineup : lineup.name, - Id = lineup.uri.Substring(18) - }); - } + Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name, + Id = lineup.Uri?[18..] + }); } } - else - { - _logger.LogInformation("No lineups available"); - } + } + else + { + _logger.LogInformation("No lineups available"); } } catch (Exception ex) @@ -558,8 +545,6 @@ namespace Emby.Server.Implementations.LiveTv.Listings return lineups; } - private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValuePair>(); - private DateTime _lastErrorResponse; private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken) { var username = info.Username; @@ -582,8 +567,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings return null; } - NameValuePair savedToken = null; - if (!_tokens.TryGetValue(username, out savedToken)) + if (!_tokens.TryGetValue(username, out NameValuePair savedToken)) { savedToken = new NameValuePair(); _tokens.TryAdd(username, savedToken); @@ -609,7 +593,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture); return result; } - catch (HttpException ex) + catch (HttpRequestException ex) { if (ex.StatusCode.HasValue) { @@ -619,6 +603,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings _lastErrorResponse = DateTime.UtcNow; } } + throw; } finally @@ -627,112 +612,64 @@ namespace Emby.Server.Implementations.LiveTv.Listings } } - private async Task<HttpResponseInfo> Post(HttpRequestOptions options, + private async Task<HttpResponseMessage> Send( + HttpRequestMessage options, bool enableRetry, - ListingsProviderInfo providerInfo) + ListingsProviderInfo providerInfo, + CancellationToken cancellationToken, + HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead) { - // Schedules direct requires that the client support compression and will return a 400 response without it - options.EnableHttpCompression = true; - - // On windows 7 under .net core, this header is not getting added -#if NETSTANDARD2_0 - if (Environment.OSVersion.Platform == PlatformID.Win32NT) + var response = await _httpClientFactory.CreateClient(NamedClient.Default) + .SendAsync(options, completionOption, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) { - options.RequestHeaders["Accept-Encoding"] = "deflate"; + return response; } -#endif - try + // Response is automatically disposed in the calling function, + // so dispose manually if not returning. + response.Dispose(); + if (!enableRetry || (int)response.StatusCode >= 500) { - return await _httpClient.Post(options).ConfigureAwait(false); + throw new HttpRequestException( + string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase), + null, + response.StatusCode); } - catch (HttpException ex) - { - _tokens.Clear(); - if (!ex.StatusCode.HasValue || (int)ex.StatusCode.Value >= 500) - { - enableRetry = false; - } - - if (!enableRetry) - { - throw; - } - } - - options.RequestHeaders["token"] = await GetToken(providerInfo, options.CancellationToken).ConfigureAwait(false); - return await Post(options, false, providerInfo).ConfigureAwait(false); + _tokens.Clear(); + options.Headers.TryAddWithoutValidation("token", await GetToken(providerInfo, cancellationToken).ConfigureAwait(false)); + return await Send(options, false, providerInfo, cancellationToken).ConfigureAwait(false); } - private async Task<HttpResponseInfo> Get(HttpRequestOptions options, - bool enableRetry, - ListingsProviderInfo providerInfo) - { - // Schedules direct requires that the client support compression and will return a 400 response without it - options.EnableHttpCompression = true; - - // On windows 7 under .net core, this header is not getting added -#if NETSTANDARD2_0 - if (Environment.OSVersion.Platform == PlatformID.Win32NT) - { - options.RequestHeaders["Accept-Encoding"] = "deflate"; - } -#endif - - try - { - return await _httpClient.SendAsync(options, "GET").ConfigureAwait(false); - } - catch (HttpException ex) - { - _tokens.Clear(); - - if (!ex.StatusCode.HasValue || (int)ex.StatusCode.Value >= 500) - { - enableRetry = false; - } - - if (!enableRetry) - { - throw; - } - } - - options.RequestHeaders["token"] = await GetToken(providerInfo, options.CancellationToken).ConfigureAwait(false); - return await Get(options, false, providerInfo).ConfigureAwait(false); - } - - private async Task<string> GetTokenInternal(string username, string password, + private async Task<string> GetTokenInternal( + string username, + string password, CancellationToken cancellationToken) { - var httpOptions = new HttpRequestOptions() - { - Url = ApiUrl + "/token", - UserAgent = UserAgent, - RequestContent = "{\"username\":\"" + username + "\",\"password\":\"" + password + "\"}", - CancellationToken = cancellationToken, - LogErrorResponseBody = true - }; - //_logger.LogInformation("Obtaining token from Schedules Direct from addres: " + httpOptions.Url + " with body " + - // httpOptions.RequestContent); + using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token"); + var hashedPasswordBytes = _cryptoProvider.ComputeHash("SHA1", Encoding.ASCII.GetBytes(password), Array.Empty<byte>()); + // TODO: remove ToLower when Convert.ToHexString supports lowercase + // Schedules Direct requires the hex to be lowercase + string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant(); + options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + "\"}", Encoding.UTF8, MediaTypeNames.Application.Json); - using (var response = await Post(httpOptions, false, null).ConfigureAwait(false)) + using var response = await Send(options, false, null, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync<TokenDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (string.Equals(root?.Message, "OK", StringComparison.Ordinal)) { - var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Token>(response.Content).ConfigureAwait(false); - if (root.message == "OK") - { - _logger.LogInformation("Authenticated with Schedules Direct token: " + root.token); - return root.token; - } - - throw new Exception("Could not authenticate with Schedules Direct Error: " + root.message); + _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token); + return root.Token; } + + throw new Exception("Could not authenticate with Schedules Direct Error: " + root.Message); } private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken) { - var token = await GetToken(info, cancellationToken); + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(token)) { @@ -746,27 +683,11 @@ namespace Emby.Server.Implementations.LiveTv.Listings _logger.LogInformation("Adding new LineUp "); - var httpOptions = new HttpRequestOptions() - { - Url = ApiUrl + "/lineups/" + info.ListingsId, - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true, - BufferContent = false - }; - - httpOptions.RequestHeaders["token"] = token; - - using (var response = await _httpClient.SendAsync(httpOptions, "PUT")) - { - } + using var options = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId); + options.Headers.TryAddWithoutValidation("token", token); + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).SendAsync(options, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); } - public string Name => "Schedules Direct"; - - public static string TypeName = "SchedulesDirect"; - public string Type => TypeName; - private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(info.ListingsId)) @@ -774,7 +695,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings throw new ArgumentException("Listings Id required"); } - var token = await GetToken(info, cancellationToken); + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(token)) { @@ -783,30 +704,23 @@ namespace Emby.Server.Implementations.LiveTv.Listings _logger.LogInformation("Headends on account "); - var options = new HttpRequestOptions() - { - Url = ApiUrl + "/lineups", - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true - }; - - options.RequestHeaders["token"] = token; + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups"); + options.Headers.TryAddWithoutValidation("token", token); try { - using (var httpResponse = await Get(options, false, null).ConfigureAwait(false)) - using (var response = httpResponse.Content) - { - var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Lineups>(response).ConfigureAwait(false); + using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false); + httpResponse.EnsureSuccessStatusCode(); + await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var response = httpResponse.Content; + var root = await JsonSerializer.DeserializeAsync<LineupsDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - return root.lineups.Any(i => string.Equals(info.ListingsId, i.lineup, StringComparison.OrdinalIgnoreCase)); - } + return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)) ?? false; } - catch (HttpException ex) + catch (HttpRequestException ex) { - // Apparently we're supposed to swallow this - if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest) + // SchedulesDirect returns 400 if no lineups are configured. + if (ex.StatusCode is HttpStatusCode.BadRequest) { return false; } @@ -823,11 +737,13 @@ namespace Emby.Server.Implementations.LiveTv.Listings { throw new ArgumentException("Username is required"); } + if (string.IsNullOrEmpty(info.Password)) { throw new ArgumentException("Password is required"); } } + if (validateListings) { if (string.IsNullOrEmpty(info.ListingsId)) @@ -857,388 +773,62 @@ namespace Emby.Server.Implementations.LiveTv.Listings throw new Exception("ListingsId required"); } - var token = await GetToken(info, cancellationToken); + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); if (string.IsNullOrEmpty(token)) { throw new Exception("token required"); } - var httpOptions = new HttpRequestOptions() + using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId); + options.Headers.TryAddWithoutValidation("token", token); + + using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false); + await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + var root = await JsonSerializer.DeserializeAsync<ChannelDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + if (root == null) { - Url = ApiUrl + "/lineups/" + listingsId, - UserAgent = UserAgent, - CancellationToken = cancellationToken, - LogErrorResponseBody = true, - // The data can be large so give it some extra time - TimeoutMs = 60000 - }; + return new List<ChannelInfo>(); + } - httpOptions.RequestHeaders["token"] = token; + _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.Map.Count); + _logger.LogInformation("Mapping Stations to Channel"); - var list = new List<ChannelInfo>(); + var allStations = root.Stations; - using (var httpResponse = await Get(httpOptions, true, info).ConfigureAwait(false)) - using (var response = httpResponse.Content) + var map = root.Map; + var list = new List<ChannelInfo>(map.Count); + foreach (var channel in map) { - var root = await _jsonSerializer.DeserializeFromStreamAsync<ScheduleDirect.Channel>(response).ConfigureAwait(false); - _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.map.Count); - _logger.LogInformation("Mapping Stations to Channel"); + var channelNumber = GetChannelNumber(channel); - var allStations = root.stations ?? Enumerable.Empty<ScheduleDirect.Station>(); + var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, StringComparison.OrdinalIgnoreCase)); + var station = stationIndex == -1 + ? new StationDto { StationId = channel.StationId } + : allStations[stationIndex]; - foreach (ScheduleDirect.Map map in root.map) + var channelInfo = new ChannelInfo { - var channelNumber = GetChannelNumber(map); - - var station = allStations.FirstOrDefault(item => string.Equals(item.stationID, map.stationID, StringComparison.OrdinalIgnoreCase)); - if (station == null) - { - station = new ScheduleDirect.Station - { - stationID = map.stationID - }; - } - - var channelInfo = new ChannelInfo - { - Id = station.stationID, - CallSign = station.callsign, - Number = channelNumber, - Name = string.IsNullOrWhiteSpace(station.name) ? channelNumber : station.name - }; - - if (station.logo != null) - { - channelInfo.ImageUrl = station.logo.URL; - } - - list.Add(channelInfo); - } - } - - return list; - } - - private ScheduleDirect.Station GetStation(List<ScheduleDirect.Station> allStations, string channelNumber, string channelName) - { - if (!string.IsNullOrWhiteSpace(channelName)) - { - channelName = NormalizeName(channelName); - - var result = allStations.FirstOrDefault(i => string.Equals(NormalizeName(i.callsign ?? string.Empty), channelName, StringComparison.OrdinalIgnoreCase)); + Id = station.StationId, + CallSign = station.Callsign, + Number = channelNumber, + Name = string.IsNullOrWhiteSpace(station.Name) ? channelNumber : station.Name + }; - if (result != null) + if (station.Logo != null) { - return result; + channelInfo.ImageUrl = station.Logo.Url; } - } - if (!string.IsNullOrWhiteSpace(channelNumber)) - { - return allStations.FirstOrDefault(i => string.Equals(NormalizeName(i.stationID ?? string.Empty), channelNumber, StringComparison.OrdinalIgnoreCase)); + list.Add(channelInfo); } - return null; + return list; } private static string NormalizeName(string value) { - return value.Replace(" ", string.Empty).Replace("-", string.Empty); - } - - public class ScheduleDirect - { - public class Token - { - public int code { get; set; } - public string message { get; set; } - public string serverID { get; set; } - public string token { get; set; } - } - public class Lineup - { - public string lineup { get; set; } - public string name { get; set; } - public string transport { get; set; } - public string location { get; set; } - public string uri { get; set; } - } - - public class Lineups - { - public int code { get; set; } - public string serverID { get; set; } - public string datetime { get; set; } - public List<Lineup> lineups { get; set; } - } - - - public class Headends - { - public string headend { get; set; } - public string transport { get; set; } - public string location { get; set; } - public List<Lineup> lineups { get; set; } - } - - - - public class Map - { - public string stationID { get; set; } - public string channel { get; set; } - public string logicalChannelNumber { get; set; } - public int uhfVhf { get; set; } - public int atscMajor { get; set; } - public int atscMinor { get; set; } - } - - public class Broadcaster - { - public string city { get; set; } - public string state { get; set; } - public string postalcode { get; set; } - public string country { get; set; } - } - - public class Logo - { - public string URL { get; set; } - public int height { get; set; } - public int width { get; set; } - public string md5 { get; set; } - } - - public class Station - { - public string stationID { get; set; } - public string name { get; set; } - public string callsign { get; set; } - public List<string> broadcastLanguage { get; set; } - public List<string> descriptionLanguage { get; set; } - public Broadcaster broadcaster { get; set; } - public string affiliate { get; set; } - public Logo logo { get; set; } - public bool? isCommercialFree { get; set; } - } - - public class Metadata - { - public string lineup { get; set; } - public string modified { get; set; } - public string transport { get; set; } - } - - public class Channel - { - public List<Map> map { get; set; } - public List<Station> stations { get; set; } - public Metadata metadata { get; set; } - } - - public class RequestScheduleForChannel - { - public string stationID { get; set; } - public List<string> date { get; set; } - } - - - - - public class Rating - { - public string body { get; set; } - public string code { get; set; } - } - - public class Multipart - { - public int partNumber { get; set; } - public int totalParts { get; set; } - } - - public class Program - { - public string programID { get; set; } - public string airDateTime { get; set; } - public int duration { get; set; } - public string md5 { get; set; } - public List<string> audioProperties { get; set; } - public List<string> videoProperties { get; set; } - public List<Rating> ratings { get; set; } - public bool? @new { get; set; } - public Multipart multipart { get; set; } - public string liveTapeDelay { get; set; } - public bool premiere { get; set; } - public bool repeat { get; set; } - public string isPremiereOrFinale { get; set; } - } - - - - public class MetadataSchedule - { - public string modified { get; set; } - public string md5 { get; set; } - public string startDate { get; set; } - public string endDate { get; set; } - public int days { get; set; } - } - - public class Day - { - public string stationID { get; set; } - public List<Program> programs { get; set; } - public MetadataSchedule metadata { get; set; } - - public Day() - { - programs = new List<Program>(); - } - } - - // - public class Title - { - public string title120 { get; set; } - } - - public class EventDetails - { - public string subType { get; set; } - } - - public class Description100 - { - public string descriptionLanguage { get; set; } - public string description { get; set; } - } - - public class Description1000 - { - public string descriptionLanguage { get; set; } - public string description { get; set; } - } - - public class DescriptionsProgram - { - public List<Description100> description100 { get; set; } - public List<Description1000> description1000 { get; set; } - } - - public class Gracenote - { - public int season { get; set; } - public int episode { get; set; } - } - - public class MetadataPrograms - { - public Gracenote Gracenote { get; set; } - } - - public class ContentRating - { - public string body { get; set; } - public string code { get; set; } - } - - public class Cast - { - public string billingOrder { get; set; } - public string role { get; set; } - public string nameId { get; set; } - public string personId { get; set; } - public string name { get; set; } - public string characterName { get; set; } - } - - public class Crew - { - public string billingOrder { get; set; } - public string role { get; set; } - public string nameId { get; set; } - public string personId { get; set; } - public string name { get; set; } - } - - public class QualityRating - { - public string ratingsBody { get; set; } - public string rating { get; set; } - public string minRating { get; set; } - public string maxRating { get; set; } - public string increment { get; set; } - } - - public class Movie - { - public string year { get; set; } - public int duration { get; set; } - public List<QualityRating> qualityRating { get; set; } - } - - public class Recommendation - { - public string programID { get; set; } - public string title120 { get; set; } - } - - public class ProgramDetails - { - public string audience { get; set; } - public string programID { get; set; } - public List<Title> titles { get; set; } - public EventDetails eventDetails { get; set; } - public DescriptionsProgram descriptions { get; set; } - public string originalAirDate { get; set; } - public List<string> genres { get; set; } - public string episodeTitle150 { get; set; } - public List<MetadataPrograms> metadata { get; set; } - public List<ContentRating> contentRating { get; set; } - public List<Cast> cast { get; set; } - public List<Crew> crew { get; set; } - public string entityType { get; set; } - public string showType { get; set; } - public bool hasImageArtwork { get; set; } - public string primaryImage { get; set; } - public string thumbImage { get; set; } - public string backdropImage { get; set; } - public string bannerImage { get; set; } - public string imageID { get; set; } - public string md5 { get; set; } - public List<string> contentAdvisory { get; set; } - public Movie movie { get; set; } - public List<Recommendation> recommendations { get; set; } - } - - public class Caption - { - public string content { get; set; } - public string lang { get; set; } - } - - public class ImageData - { - public string width { get; set; } - public string height { get; set; } - public string uri { get; set; } - public string size { get; set; } - public string aspect { get; set; } - public string category { get; set; } - public string text { get; set; } - public string primary { get; set; } - public string tier { get; set; } - public Caption caption { get; set; } - } - - public class ShowImages - { - public string programID { get; set; } - public List<ImageData> data { get; set; } - } - + return value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("-", string.Empty, StringComparison.Ordinal); } } } |
