< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.Listings.SchedulesDirect
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
Line coverage
9%
Covered lines: 14
Uncovered lines: 133
Coverable lines: 147
Total lines: 815
Line coverage: 9.5%
Branch coverage
4%
Covered branches: 4
Total branches: 98
Branch coverage: 4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Name()100%210%
get_Type()100%210%
GetScheduleRequestDates(...)0%620%
GetSizeOrder(...)0%620%
GetChannelNumber(...)0%2040%
IsMovie(...)100%210%
GetProgram(...)0%4970700%
GetProgramImage(...)0%4260%
GetAspectRatio(...)0%7280%
GetLineups(...)100%210%
Dispose()100%11100%
Dispose(...)66.66%6.17683.33%

File(s)

/srv/git/jellyfin/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs

#LineLine coverage
 1#nullable disable
 2
 3#pragma warning disable CS1591
 4
 5using System;
 6using System.Collections.Concurrent;
 7using System.Collections.Generic;
 8using System.Globalization;
 9using System.Linq;
 10using System.Net;
 11using System.Net.Http;
 12using System.Net.Http.Json;
 13using System.Net.Mime;
 14using System.Security.Cryptography;
 15using System.Text;
 16using System.Text.Json;
 17using System.Threading;
 18using System.Threading.Tasks;
 19using AsyncKeyedLock;
 20using Jellyfin.Extensions;
 21using Jellyfin.Extensions.Json;
 22using Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
 23using MediaBrowser.Common.Net;
 24using MediaBrowser.Controller.Authentication;
 25using MediaBrowser.Controller.LiveTv;
 26using MediaBrowser.Model.Dto;
 27using MediaBrowser.Model.Entities;
 28using MediaBrowser.Model.LiveTv;
 29using Microsoft.Extensions.Logging;
 30
 31namespace Jellyfin.LiveTv.Listings
 32{
 33    public class SchedulesDirect : IListingsProvider, IDisposable
 34    {
 35        private const string ApiUrl = "https://json.schedulesdirect.org/20141201";
 36
 37        private readonly ILogger<SchedulesDirect> _logger;
 38        private readonly IHttpClientFactory _httpClientFactory;
 2239        private readonly AsyncNonKeyedLocker _tokenLock = new(1);
 40
 2241        private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new ConcurrentDictionary<string, NameValu
 2242        private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
 43        private DateTime _lastErrorResponse;
 44        private bool _disposed = false;
 45
 46        public SchedulesDirect(
 47            ILogger<SchedulesDirect> logger,
 48            IHttpClientFactory httpClientFactory)
 49        {
 2250            _logger = logger;
 2251            _httpClientFactory = httpClientFactory;
 2252        }
 53
 54        /// <inheritdoc />
 055        public string Name => "Schedules Direct";
 56
 57        /// <inheritdoc />
 058        public string Type => nameof(SchedulesDirect);
 59
 60        private static List<string> GetScheduleRequestDates(DateTime startDateUtc, DateTime endDateUtc)
 61        {
 062            var dates = new List<string>();
 63
 064            var start = new[] { startDateUtc, startDateUtc.ToLocalTime() }.Min().Date;
 065            var end = new[] { endDateUtc, endDateUtc.ToLocalTime() }.Max().Date;
 66
 067            while (start <= end)
 68            {
 069                dates.Add(start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
 070                start = start.AddDays(1);
 71            }
 72
 073            return dates;
 74        }
 75
 76        public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTi
 77        {
 78            ArgumentException.ThrowIfNullOrEmpty(channelId);
 79
 80            // Normalize incoming input
 81            channelId = channelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase)
 82
 83            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 84
 85            if (string.IsNullOrEmpty(token))
 86            {
 87                _logger.LogWarning("SchedulesDirect token is empty, returning empty program list");
 88
 89                return Enumerable.Empty<ProgramInfo>();
 90            }
 91
 92            var dates = GetScheduleRequestDates(startDateUtc, endDateUtc);
 93
 94            _logger.LogInformation("Channel Station ID is: {ChannelID}", channelId);
 95            var requestList = new List<RequestScheduleForChannelDto>()
 96                {
 97                    new RequestScheduleForChannelDto()
 98                    {
 99                        StationId = channelId,
 100                        Date = dates
 101                    }
 102                };
 103
 104            _logger.LogDebug("Request string for schedules is: {@RequestString}", requestList);
 105
 106            using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules");
 107            options.Content = JsonContent.Create(requestList, options: _jsonOptions);
 108            options.Headers.TryAddWithoutValidation("token", token);
 109            var dailySchedules = await Request<IReadOnlyList<DayDto>>(options, true, info, cancellationToken).ConfigureA
 110            if (dailySchedules is null)
 111            {
 112                return Array.Empty<ProgramInfo>();
 113            }
 114
 115            _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, chann
 116
 117            using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs");
 118            programRequestOptions.Headers.TryAddWithoutValidation("token", token);
 119
 120            var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct();
 121            programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions);
 122
 123            var programDetails = await Request<IReadOnlyList<ProgramDetailsDto>>(programRequestOptions, true, info, canc
 124                    .ConfigureAwait(false);
 125            if (programDetails is null)
 126            {
 127                return Array.Empty<ProgramInfo>();
 128            }
 129
 130            var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y);
 131
 132            var programIdsWithImages = programDetails
 133                .Where(p => p.HasImageArtwork).Select(p => p.ProgramId)
 134                .ToList();
 135
 136            var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false);
 137
 138            var programsInfo = new List<ProgramInfo>();
 139            foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs))
 140            {
 141                // _logger.LogDebug("Proccesing Schedule for statio ID " + stationID +
 142                //              " which corresponds to channel " + channelNumber + " and program id " +
 143                //              schedule.ProgramId + " which says it has images? " +
 144                //              programDict[schedule.ProgramId].hasImageArtwork);
 145
 146                if (string.IsNullOrEmpty(schedule.ProgramId))
 147                {
 148                    continue;
 149                }
 150
 151                if (images is not null)
 152                {
 153                    var imageIndex = images.FindIndex(i => i.ProgramId == schedule.ProgramId[..10]);
 154                    if (imageIndex > -1)
 155                    {
 156                        var programEntry = programDict[schedule.ProgramId];
 157
 158                        var allImages = images[imageIndex].Data;
 159                        var imagesWithText = allImages.Where(i => string.Equals(i.Text, "yes", StringComparison.OrdinalI
 160                        var imagesWithoutText = allImages.Where(i => string.Equals(i.Text, "no", StringComparison.Ordina
 161
 162                        const double DesiredAspect = 2.0 / 3;
 163
 164                        programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, DesiredAspect, token) ??
 165                                                    GetProgramImage(ApiUrl, allImages, DesiredAspect, token);
 166
 167                        const double WideAspect = 16.0 / 9;
 168
 169                        programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, WideAspect, token);
 170
 171                        // Don't supply the same image twice
 172                        if (string.Equals(programEntry.PrimaryImage, programEntry.ThumbImage, StringComparison.Ordinal))
 173                        {
 174                            programEntry.ThumbImage = null;
 175                        }
 176
 177                        programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, WideAspect, token);
 178
 179                        // programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ??
 180                        //    GetProgramImage(ApiUrl, data, "Banner-L1", false) ??
 181                        //    GetProgramImage(ApiUrl, data, "Banner-LO", false) ??
 182                        //    GetProgramImage(ApiUrl, data, "Banner-LOT", false);
 183                    }
 184                }
 185
 186                programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.ProgramId]));
 187            }
 188
 189            return programsInfo;
 190        }
 191
 192        private static int GetSizeOrder(ImageDataDto image)
 193        {
 0194            if (int.TryParse(image.Height, out int value))
 195            {
 0196                return value;
 197            }
 198
 0199            return 0;
 200        }
 201
 202        private static string GetChannelNumber(MapDto map)
 203        {
 0204            var channelNumber = map.LogicalChannelNumber;
 205
 0206            if (string.IsNullOrWhiteSpace(channelNumber))
 207            {
 0208                channelNumber = map.Channel;
 209            }
 210
 0211            if (string.IsNullOrWhiteSpace(channelNumber))
 212            {
 0213                channelNumber = map.AtscMajor + "." + map.AtscMinor;
 214            }
 215
 0216            return channelNumber.TrimStart('0');
 217        }
 218
 219        private static bool IsMovie(ProgramDetailsDto programInfo)
 220        {
 0221            return string.Equals(programInfo.EntityType, "movie", StringComparison.OrdinalIgnoreCase);
 222        }
 223
 224        private ProgramInfo GetProgram(string channelId, ProgramDto programInfo, ProgramDetailsDto details)
 225        {
 0226            if (programInfo.AirDateTime is null)
 227            {
 0228                return null;
 229            }
 230
 0231            var startAt = programInfo.AirDateTime.Value;
 0232            var endAt = startAt.AddSeconds(programInfo.Duration);
 0233            var audioType = ProgramAudio.Stereo;
 234
 0235            var programId = programInfo.ProgramId ?? string.Empty;
 236
 0237            string newID = programId + "T" + startAt.Ticks + "C" + channelId;
 238
 0239            if (programInfo.AudioProperties.Count != 0)
 240            {
 0241                if (programInfo.AudioProperties.Contains("atmos", StringComparison.OrdinalIgnoreCase))
 242                {
 0243                    audioType = ProgramAudio.Atmos;
 244                }
 0245                else if (programInfo.AudioProperties.Contains("dd 5.1", StringComparison.OrdinalIgnoreCase))
 246                {
 0247                    audioType = ProgramAudio.DolbyDigital;
 248                }
 0249                else if (programInfo.AudioProperties.Contains("dd", StringComparison.OrdinalIgnoreCase))
 250                {
 0251                    audioType = ProgramAudio.DolbyDigital;
 252                }
 0253                else if (programInfo.AudioProperties.Contains("stereo", StringComparison.OrdinalIgnoreCase))
 254                {
 0255                    audioType = ProgramAudio.Stereo;
 256                }
 257                else
 258                {
 0259                    audioType = ProgramAudio.Mono;
 260                }
 261            }
 262
 0263            string episodeTitle = null;
 0264            if (details.EpisodeTitle150 is not null)
 265            {
 0266                episodeTitle = details.EpisodeTitle150;
 267            }
 268
 0269            var info = new ProgramInfo
 0270            {
 0271                ChannelId = channelId,
 0272                Id = newID,
 0273                StartDate = startAt,
 0274                EndDate = endAt,
 0275                Name = details.Titles[0].Title120 ?? "Unknown",
 0276                OfficialRating = null,
 0277                CommunityRating = null,
 0278                EpisodeTitle = episodeTitle,
 0279                Audio = audioType,
 0280                // IsNew = programInfo.@new ?? false,
 0281                IsRepeat = programInfo.New is null,
 0282                IsSeries = string.Equals(details.EntityType, "episode", StringComparison.OrdinalIgnoreCase),
 0283                ImageUrl = details.PrimaryImage,
 0284                ThumbImageUrl = details.ThumbImage,
 0285                IsKids = string.Equals(details.Audience, "children", StringComparison.OrdinalIgnoreCase),
 0286                IsSports = string.Equals(details.EntityType, "sports", StringComparison.OrdinalIgnoreCase),
 0287                IsMovie = IsMovie(details),
 0288                Etag = programInfo.Md5,
 0289                IsLive = string.Equals(programInfo.LiveTapeDelay, "live", StringComparison.OrdinalIgnoreCase),
 0290                IsPremiere = programInfo.Premiere || (programInfo.IsPremiereOrFinale ?? string.Empty).Contains("premiere
 0291            };
 292
 0293            var showId = programId;
 294
 0295            if (!info.IsSeries)
 296            {
 297                // It's also a series if it starts with SH
 0298                info.IsSeries = showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) && showId.Length >= 14;
 299            }
 300
 301            // According to SchedulesDirect, these are generic, unidentified episodes
 302            // SH005316560000
 0303            var hasUniqueShowId = !showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) ||
 0304                !showId.EndsWith("0000", StringComparison.OrdinalIgnoreCase);
 305
 0306            if (!hasUniqueShowId)
 307            {
 0308                showId = newID;
 309            }
 310
 0311            info.ShowId = showId;
 312
 0313            if (programInfo.VideoProperties is not null)
 314            {
 0315                info.IsHD = programInfo.VideoProperties.Contains("hdtv", StringComparison.OrdinalIgnoreCase);
 0316                info.Is3D = programInfo.VideoProperties.Contains("3d", StringComparison.OrdinalIgnoreCase);
 317            }
 318
 0319            if (details.ContentRating is not null && details.ContentRating.Count > 0)
 320            {
 0321                info.OfficialRating = details.ContentRating[0].Code.Replace("TV", "TV-", StringComparison.Ordinal)
 0322                    .Replace("--", "-", StringComparison.Ordinal);
 323
 0324                var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" };
 0325                if (invalid.Contains(info.OfficialRating, StringComparison.OrdinalIgnoreCase))
 326                {
 0327                    info.OfficialRating = null;
 328                }
 329            }
 330
 0331            if (details.Descriptions is not null)
 332            {
 0333                if (details.Descriptions.Description1000 is not null && details.Descriptions.Description1000.Count > 0)
 334                {
 0335                    info.Overview = details.Descriptions.Description1000[0].Description;
 336                }
 0337                else if (details.Descriptions.Description100 is not null && details.Descriptions.Description100.Count > 
 338                {
 0339                    info.Overview = details.Descriptions.Description100[0].Description;
 340                }
 341            }
 342
 0343            if (info.IsSeries)
 344            {
 0345                info.SeriesId = programId.Substring(0, 10);
 346
 0347                info.SeriesProviderIds[MetadataProvider.Zap2It.ToString()] = info.SeriesId;
 348
 0349                if (details.Metadata is not null)
 350                {
 0351                    foreach (var metadataProgram in details.Metadata)
 352                    {
 0353                        var gracenote = metadataProgram.Gracenote;
 0354                        if (gracenote is not null)
 355                        {
 0356                            info.SeasonNumber = gracenote.Season;
 357
 0358                            if (gracenote.Episode > 0)
 359                            {
 0360                                info.EpisodeNumber = gracenote.Episode;
 361                            }
 362
 0363                            break;
 364                        }
 365                    }
 366                }
 367            }
 368
 0369            if (details.OriginalAirDate is not null)
 370            {
 0371                info.OriginalAirDate = details.OriginalAirDate;
 0372                info.ProductionYear = info.OriginalAirDate.Value.Year;
 373            }
 374
 0375            if (details.Movie is not null)
 376            {
 0377                if (!string.IsNullOrEmpty(details.Movie.Year)
 0378                    && int.TryParse(details.Movie.Year, out int year))
 379                {
 0380                    info.ProductionYear = year;
 381                }
 382            }
 383
 0384            if (details.Genres is not null)
 385            {
 0386                info.Genres = details.Genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList();
 0387                info.IsNews = details.Genres.Contains("news", StringComparison.OrdinalIgnoreCase);
 388
 0389                if (info.Genres.Contains("children", StringComparison.OrdinalIgnoreCase))
 390                {
 0391                    info.IsKids = true;
 392                }
 393            }
 394
 0395            return info;
 396        }
 397
 398        private static string GetProgramImage(string apiUrl, IEnumerable<ImageDataDto> images, double desiredAspect, str
 399        {
 0400            var match = images
 0401                .OrderBy(i => Math.Abs(desiredAspect - GetAspectRatio(i)))
 0402                .ThenByDescending(i => GetSizeOrder(i))
 0403                .FirstOrDefault();
 404
 0405            if (match is null)
 406            {
 0407                return null;
 408            }
 409
 0410            var uri = match.Uri;
 411
 0412            if (string.IsNullOrWhiteSpace(uri))
 413            {
 0414                return null;
 415            }
 416
 0417            if (uri.Contains("http", StringComparison.OrdinalIgnoreCase))
 418            {
 0419                return uri;
 420            }
 421
 0422            return apiUrl + "/image/" + uri + "?token=" + token;
 423        }
 424
 425        private static double GetAspectRatio(ImageDataDto i)
 426        {
 0427            int width = 0;
 0428            int height = 0;
 429
 0430            if (!string.IsNullOrWhiteSpace(i.Width))
 431            {
 0432                _ = int.TryParse(i.Width, out width);
 433            }
 434
 0435            if (!string.IsNullOrWhiteSpace(i.Height))
 436            {
 0437                _ = int.TryParse(i.Height, out height);
 438            }
 439
 0440            if (height == 0 || width == 0)
 441            {
 0442                return 0;
 443            }
 444
 0445            double result = width;
 0446            result /= height;
 0447            return result;
 448        }
 449
 450        private async Task<IReadOnlyList<ShowImagesDto>> GetImageForPrograms(
 451            ListingsProviderInfo info,
 452            IReadOnlyList<string> programIds,
 453            CancellationToken cancellationToken)
 454        {
 455            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 456
 457            if (programIds.Count == 0)
 458            {
 459                return Array.Empty<ShowImagesDto>();
 460            }
 461
 462            StringBuilder str = new StringBuilder("[", 1 + (programIds.Count * 13));
 463            foreach (var i in programIds)
 464            {
 465                str.Append('"')
 466                    .Append(i[..10])
 467                    .Append("\",");
 468            }
 469
 470            // Remove last ,
 471            str.Length--;
 472            str.Append(']');
 473
 474            using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs");
 475            message.Headers.TryAddWithoutValidation("token", token);
 476            message.Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json);
 477
 478            try
 479            {
 480                return await Request<IReadOnlyList<ShowImagesDto>>(message, true, info, cancellationToken).ConfigureAwai
 481            }
 482            catch (Exception ex)
 483            {
 484                _logger.LogError(ex, "Error getting image info from schedules direct");
 485
 486                return Array.Empty<ShowImagesDto>();
 487            }
 488        }
 489
 490        public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, Canc
 491        {
 492            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 493
 494            var lineups = new List<NameIdPair>();
 495
 496            if (string.IsNullOrWhiteSpace(token))
 497            {
 498                return lineups;
 499            }
 500
 501            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&posta
 502            options.Headers.TryAddWithoutValidation("token", token);
 503
 504            try
 505            {
 506                var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureA
 507                if (root is not null)
 508                {
 509                    foreach (HeadendsDto headend in root)
 510                    {
 511                        foreach (LineupDto lineup in headend.Lineups)
 512                        {
 513                            lineups.Add(new NameIdPair
 514                            {
 515                                Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name,
 516                                Id = lineup.Uri?[18..]
 517                            });
 518                        }
 519                    }
 520                }
 521                else
 522                {
 523                    _logger.LogInformation("No lineups available");
 524                }
 525            }
 526            catch (Exception ex)
 527            {
 528                _logger.LogError(ex, "Error getting headends");
 529            }
 530
 531            return lineups;
 532        }
 533
 534        private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken)
 535        {
 536            var username = info.Username;
 537
 538            // Reset the token if there's no username
 539            if (string.IsNullOrWhiteSpace(username))
 540            {
 541                return null;
 542            }
 543
 544            var password = info.Password;
 545            if (string.IsNullOrEmpty(password))
 546            {
 547                return null;
 548            }
 549
 550            // Avoid hammering SD
 551            if ((DateTime.UtcNow - _lastErrorResponse).TotalMinutes < 1)
 552            {
 553                return null;
 554            }
 555
 556            if (!_tokens.TryGetValue(username, out NameValuePair savedToken))
 557            {
 558                savedToken = new NameValuePair();
 559                _tokens.TryAdd(username, savedToken);
 560            }
 561
 562            if (!string.IsNullOrEmpty(savedToken.Name)
 563                && long.TryParse(savedToken.Value, CultureInfo.InvariantCulture, out long ticks))
 564            {
 565                // If it's under 24 hours old we can still use it
 566                if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks)
 567                {
 568                    return savedToken.Name;
 569                }
 570            }
 571
 572            using (await _tokenLock.LockAsync(cancellationToken).ConfigureAwait(false))
 573            {
 574                try
 575                {
 576                    var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false);
 577                    savedToken.Name = result;
 578                    savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture);
 579                    return result;
 580                }
 581                catch (HttpRequestException ex)
 582                {
 583                    if (ex.StatusCode.HasValue && ex.StatusCode.Value == HttpStatusCode.BadRequest)
 584                    {
 585                        _tokens.Clear();
 586                        _lastErrorResponse = DateTime.UtcNow;
 587                    }
 588
 589                    throw;
 590                }
 591            }
 592        }
 593
 594        private async Task<T> Request<T>(
 595            HttpRequestMessage message,
 596            bool enableRetry,
 597            ListingsProviderInfo providerInfo,
 598            CancellationToken cancellationToken,
 599            HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
 600        {
 601            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 602                .SendAsync(message, completionOption, cancellationToken)
 603                .ConfigureAwait(false);
 604            if (response.IsSuccessStatusCode)
 605            {
 606                return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken).ConfigureAwait(false
 607            }
 608
 609            if (!enableRetry || (int)response.StatusCode >= 500)
 610            {
 611                _logger.LogError(
 612                    "Request to {Url} failed with response {Response}",
 613                    message.RequestUri,
 614                    await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
 615
 616                throw new HttpRequestException(
 617                    string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
 618                    null,
 619                    response.StatusCode);
 620            }
 621
 622            _tokens.Clear();
 623            using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri);
 624            retryMessage.Content = message.Content;
 625            retryMessage.Headers.TryAddWithoutValidation(
 626                "token",
 627                await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
 628
 629            return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false);
 630        }
 631
 632        private async Task<string> GetTokenInternal(
 633            string username,
 634            string password,
 635            CancellationToken cancellationToken)
 636        {
 637            using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
 638#pragma warning disable CA5350 // SchedulesDirect is always SHA1.
 639            var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password));
 640#pragma warning restore CA5350
 641            // TODO: remove ToLower when Convert.ToHexString supports lowercase
 642            // Schedules Direct requires the hex to be lowercase
 643            string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant();
 644            options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + 
 645
 646            var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false);
 647            if (string.Equals(root?.Message, "OK", StringComparison.Ordinal))
 648            {
 649                _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token);
 650                return root.Token;
 651            }
 652
 653            throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message);
 654        }
 655
 656        private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken)
 657        {
 658            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 659
 660            ArgumentException.ThrowIfNullOrEmpty(token);
 661            ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 662
 663            _logger.LogInformation("Adding new lineup {Id}", info.ListingsId);
 664
 665            using var message = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId);
 666            message.Headers.TryAddWithoutValidation("token", token);
 667
 668            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 669                .SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
 670                .ConfigureAwait(false);
 671
 672            if (!response.IsSuccessStatusCode)
 673            {
 674                _logger.LogError(
 675                    "Error adding lineup to account: {Response}",
 676                    await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
 677            }
 678        }
 679
 680        private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken)
 681        {
 682            ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 683
 684            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 685
 686            ArgumentException.ThrowIfNullOrEmpty(token);
 687
 688            _logger.LogInformation("Headends on account ");
 689
 690            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups");
 691            options.Headers.TryAddWithoutValidation("token", token);
 692
 693            try
 694            {
 695                var root = await Request<LineupsDto>(options, false, null, cancellationToken).ConfigureAwait(false);
 696                return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCas
 697            }
 698            catch (HttpRequestException ex)
 699            {
 700                // SchedulesDirect returns 400 if no lineups are configured.
 701                if (ex.StatusCode is HttpStatusCode.BadRequest)
 702                {
 703                    return false;
 704                }
 705
 706                throw;
 707            }
 708        }
 709
 710        public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
 711        {
 712            if (validateLogin)
 713            {
 714                ArgumentException.ThrowIfNullOrEmpty(info.Username);
 715                ArgumentException.ThrowIfNullOrEmpty(info.Password);
 716            }
 717
 718            if (validateListings)
 719            {
 720                ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 721
 722                var hasLineup = await HasLineup(info, CancellationToken.None).ConfigureAwait(false);
 723
 724                if (!hasLineup)
 725                {
 726                    await AddLineupToAccount(info, CancellationToken.None).ConfigureAwait(false);
 727                }
 728            }
 729        }
 730
 731        public Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location)
 732        {
 0733            return GetHeadends(info, country, location, CancellationToken.None);
 734        }
 735
 736        public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
 737        {
 738            var listingsId = info.ListingsId;
 739            ArgumentException.ThrowIfNullOrEmpty(listingsId);
 740
 741            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 742
 743            ArgumentException.ThrowIfNullOrEmpty(token);
 744
 745            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
 746            options.Headers.TryAddWithoutValidation("token", token);
 747
 748            var root = await Request<ChannelDto>(options, true, info, cancellationToken).ConfigureAwait(false);
 749            if (root is null)
 750            {
 751                return new List<ChannelInfo>();
 752            }
 753
 754            _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.Map.Count);
 755            _logger.LogInformation("Mapping Stations to Channel");
 756
 757            var allStations = root.Stations;
 758
 759            var map = root.Map;
 760            var list = new List<ChannelInfo>(map.Count);
 761            foreach (var channel in map)
 762            {
 763                var channelNumber = GetChannelNumber(channel);
 764
 765                var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, String
 766                var station = stationIndex == -1
 767                    ? new StationDto { StationId = channel.StationId }
 768                    : allStations[stationIndex];
 769
 770                var channelInfo = new ChannelInfo
 771                {
 772                    Id = station.StationId,
 773                    CallSign = station.Callsign,
 774                    Number = channelNumber,
 775                    Name = string.IsNullOrWhiteSpace(station.Name) ? channelNumber : station.Name
 776                };
 777
 778                if (station.Logo is not null)
 779                {
 780                    channelInfo.ImageUrl = station.Logo.Url;
 781                }
 782
 783                list.Add(channelInfo);
 784            }
 785
 786            return list;
 787        }
 788
 789        /// <inheritdoc />
 790        public void Dispose()
 791        {
 22792            Dispose(true);
 22793            GC.SuppressFinalize(this);
 22794        }
 795
 796        /// <summary>
 797        /// Releases unmanaged and optionally managed resources.
 798        /// </summary>
 799        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release
 800        protected virtual void Dispose(bool disposing)
 801        {
 22802            if (_disposed)
 803            {
 0804                return;
 805            }
 806
 22807            if (disposing)
 808            {
 22809                _tokenLock?.Dispose();
 810            }
 811
 22812            _disposed = true;
 22813        }
 814    }
 815}