< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.Listings.SchedulesDirect
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
Line coverage
4%
Covered lines: 22
Uncovered lines: 477
Coverable lines: 499
Total lines: 1088
Line coverage: 4.4%
Branch coverage
1%
Covered branches: 5
Total branches: 297
Branch coverage: 1.6%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 3/8/2026 - 12:12:00 AM Line coverage: 9.5% (14/147) Branch coverage: 4% (4/98) Total lines: 8144/19/2026 - 12:14:27 AM Line coverage: 3.7% (14/370) Branch coverage: 2% (4/198) Total lines: 8145/6/2026 - 12:15:23 AM Line coverage: 4.4% (22/492) Branch coverage: 2% (6/287) Total lines: 10785/20/2026 - 12:15:44 AM Line coverage: 4.4% (22/492) Branch coverage: 1.7% (5/287) Total lines: 10786/1/2026 - 12:16:05 AM Line coverage: 4.4% (22/499) Branch coverage: 1.6% (5/297) Total lines: 1088 3/8/2026 - 12:12:00 AM Line coverage: 9.5% (14/147) Branch coverage: 4% (4/98) Total lines: 8144/19/2026 - 12:14:27 AM Line coverage: 3.7% (14/370) Branch coverage: 2% (4/198) Total lines: 8145/6/2026 - 12:15:23 AM Line coverage: 4.4% (22/492) Branch coverage: 2% (6/287) Total lines: 10785/20/2026 - 12:15:44 AM Line coverage: 4.4% (22/492) Branch coverage: 1.7% (5/287) Total lines: 10786/1/2026 - 12:16:05 AM Line coverage: 4.4% (22/499) Branch coverage: 1.6% (5/297) Total lines: 1088

Coverage delta

Coverage delta 6 -6

Metrics

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.IO;
 10using System.Linq;
 11using System.Net;
 12using System.Net.Http;
 13using System.Net.Http.Json;
 14using System.Net.Mime;
 15using System.Security.Cryptography;
 16using System.Text;
 17using System.Text.Json;
 18using System.Threading;
 19using System.Threading.Tasks;
 20using AsyncKeyedLock;
 21using Jellyfin.Extensions;
 22using Jellyfin.Extensions.Json;
 23using Jellyfin.LiveTv.Guide;
 24using Jellyfin.LiveTv.Listings.SchedulesDirectDtos;
 25using MediaBrowser.Common.Configuration;
 26using MediaBrowser.Common.Net;
 27using MediaBrowser.Controller.Authentication;
 28using MediaBrowser.Controller.LiveTv;
 29using MediaBrowser.Model.Dto;
 30using MediaBrowser.Model.Entities;
 31using MediaBrowser.Model.LiveTv;
 32using Microsoft.Extensions.Logging;
 33
 34namespace Jellyfin.LiveTv.Listings
 35{
 36    public class SchedulesDirect : IListingsProvider, ISchedulesDirectService, IDisposable
 37    {
 38        private const string ApiUrl = "https://json.schedulesdirect.org/20141201";
 39        private const int CountryCacheDays = 7;
 40
 41        private readonly ILogger<SchedulesDirect> _logger;
 42        private readonly IHttpClientFactory _httpClientFactory;
 43        private readonly IApplicationPaths _appPaths;
 2144        private readonly AsyncNonKeyedLocker _tokenLock = new(1);
 45
 2146        private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new();
 2147        private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
 48        private long _lastErrorResponseTicks;
 49        private volatile bool _accountError;
 50        private bool _disposed = false;
 51
 52        private byte[] _countriesCache;
 53        private DateOnly? _imageLimitHitDate;
 54        private DateOnly? _metadataLimitHitDate;
 55
 56        public SchedulesDirect(
 57            ILogger<SchedulesDirect> logger,
 58            IHttpClientFactory httpClientFactory,
 59            IApplicationPaths appPaths)
 60        {
 2161            _logger = logger;
 2162            _httpClientFactory = httpClientFactory;
 2163            _appPaths = appPaths;
 2164            _imageLimitHitDate = LoadDailyLimitDate(ImageLimitFilePath);
 2165            _metadataLimitHitDate = LoadDailyLimitDate(MetadataLimitFilePath);
 2166        }
 67
 68        /// <inheritdoc />
 069        public string Name => "Schedules Direct";
 70
 2171        private string ImageLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-image-limit.txt");
 72
 2173        private string MetadataLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-metadata-limit.txt");
 74
 75        /// <inheritdoc />
 076        public string Type => nameof(SchedulesDirect);
 77
 78        private static List<string> GetScheduleRequestDates(DateTime startDateUtc, DateTime endDateUtc)
 79        {
 080            var dates = new List<string>();
 81
 082            var start = new[] { startDateUtc, startDateUtc.ToLocalTime() }.Min().Date;
 083            var end = new[] { endDateUtc, endDateUtc.ToLocalTime() }.Max().Date;
 84
 085            while (start <= end)
 86            {
 087                dates.Add(start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
 088                start = start.AddDays(1);
 89            }
 90
 091            return dates;
 92        }
 93
 94        public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTi
 95        {
 096            if (IsMetadataLimitActive())
 97            {
 098                return [];
 99            }
 100
 0101            ArgumentException.ThrowIfNullOrEmpty(channelId);
 102
 103            // Normalize incoming input
 0104            channelId = channelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.OrdinalIgnoreCase)
 105
 0106            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 107
 0108            if (string.IsNullOrEmpty(token))
 109            {
 0110                _logger.LogWarning("SchedulesDirect token is empty, returning empty program list");
 111
 0112                return [];
 113            }
 114
 0115            var dates = GetScheduleRequestDates(startDateUtc, endDateUtc);
 116
 0117            _logger.LogInformation("Channel Station ID is: {ChannelID}", channelId);
 0118            var requestList = new List<RequestScheduleForChannelDto>()
 0119                {
 0120                    new()
 0121                    {
 0122                        StationId = channelId,
 0123                        Date = dates
 0124                    }
 0125                };
 126
 0127            _logger.LogDebug("Request string for schedules is: {@RequestString}", requestList);
 128
 0129            using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/schedules");
 0130            options.Content = JsonContent.Create(requestList, options: _jsonOptions);
 0131            options.Headers.TryAddWithoutValidation("token", token);
 0132            var dailySchedules = await Request<IReadOnlyList<DayDto>>(options, true, info, cancellationToken).ConfigureA
 0133            if (dailySchedules is null)
 134            {
 0135                return [];
 136            }
 137
 0138            _logger.LogDebug("Found {ScheduleCount} programs on {ChannelID} ScheduleDirect", dailySchedules.Count, chann
 139
 0140            using var programRequestOptions = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/programs");
 0141            programRequestOptions.Headers.TryAddWithoutValidation("token", token);
 142
 0143            var programIds = dailySchedules.SelectMany(d => d.Programs.Select(s => s.ProgramId)).Distinct();
 0144            programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions);
 145
 0146            var programDetails = await Request<IReadOnlyList<ProgramDetailsDto>>(programRequestOptions, true, info, canc
 0147            if (programDetails is null)
 148            {
 0149                return [];
 150            }
 151
 0152            var programDict = programDetails.ToDictionary(p => p.ProgramId, y => y);
 153
 0154            var programIdsWithImages = programDetails
 0155                .Where(p => p.HasImageArtwork)
 0156                .Select(p => p.ProgramId)
 0157                .ToList();
 158
 0159            var images = await GetImageForPrograms(info, programIdsWithImages, cancellationToken).ConfigureAwait(false);
 160
 0161            var programsInfo = new List<ProgramInfo>();
 0162            foreach (ProgramDto schedule in dailySchedules.SelectMany(d => d.Programs))
 163            {
 0164                if (string.IsNullOrEmpty(schedule.ProgramId))
 165                {
 166                    continue;
 167                }
 168
 169                // Only add images which will be pre-cached until we can implement dynamic token fetching
 0170                var endDate = schedule.AirDateTime?.AddSeconds(schedule.Duration);
 0171                var willBeCached = endDate.HasValue && endDate.Value < DateTime.UtcNow.AddDays(GuideManager.MaxCacheDays
 0172                if (willBeCached && images is not null)
 173                {
 0174                    var imageIndex = images.FindIndex(i =>
 0175                        i.ProgramId is not null && schedule.ProgramId.StartsWith(i.ProgramId, StringComparison.Ordinal))
 0176                    if (imageIndex > -1)
 177                    {
 0178                        var programEntry = programDict[schedule.ProgramId];
 179
 0180                        var allImages = images[imageIndex].Data;
 0181                        var imagesWithText = allImages.Where(i => string.Equals(i.Text, "yes", StringComparison.OrdinalI
 0182                        var imagesWithoutText = allImages.Where(i => string.Equals(i.Text, "no", StringComparison.Ordina
 183
 184                        const double DesiredAspect = 2.0 / 3;
 185
 0186                        programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, DesiredAspect, token) ??
 0187                                                    GetProgramImage(ApiUrl, allImages, DesiredAspect, token);
 188
 189                        const double WideAspect = 16.0 / 9;
 190
 0191                        programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, WideAspect, token);
 192
 193                        // Don't supply the same image twice
 0194                        if (string.Equals(programEntry.PrimaryImage, programEntry.ThumbImage, StringComparison.Ordinal))
 195                        {
 0196                            programEntry.ThumbImage = null;
 197                        }
 198
 0199                        programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, WideAspect, token);
 200
 201                        // programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ??
 202                        //    GetProgramImage(ApiUrl, data, "Banner-L1", false) ??
 203                        //    GetProgramImage(ApiUrl, data, "Banner-LO", false) ??
 204                        //    GetProgramImage(ApiUrl, data, "Banner-LOT", false);
 205                    }
 206                }
 207
 0208                programsInfo.Add(GetProgram(channelId, schedule, programDict[schedule.ProgramId]));
 209            }
 210
 0211            return programsInfo;
 0212        }
 213
 214        private static int GetSizeOrder(ImageDataDto image)
 215        {
 0216            if (int.TryParse(image.Height, out int value))
 217            {
 0218                return value;
 219            }
 220
 0221            return 0;
 222        }
 223
 224        private static string GetChannelNumber(MapDto map)
 225        {
 0226            var channelNumber = map.LogicalChannelNumber;
 227
 0228            if (string.IsNullOrWhiteSpace(channelNumber))
 229            {
 0230                channelNumber = map.Channel;
 231            }
 232
 0233            if (string.IsNullOrWhiteSpace(channelNumber))
 234            {
 0235                channelNumber = map.AtscMajor + "." + map.AtscMinor;
 236            }
 237
 0238            return channelNumber.TrimStart('0');
 239        }
 240
 241        private static bool IsMovie(ProgramDetailsDto programInfo)
 242        {
 0243            return string.Equals(programInfo.EntityType, "movie", StringComparison.OrdinalIgnoreCase);
 244        }
 245
 246        private ProgramInfo GetProgram(string channelId, ProgramDto programInfo, ProgramDetailsDto details)
 247        {
 0248            if (programInfo.AirDateTime is null)
 249            {
 0250                return null;
 251            }
 252
 0253            var startAt = programInfo.AirDateTime.Value;
 0254            var endAt = startAt.AddSeconds(programInfo.Duration);
 0255            var audioType = ProgramAudio.Stereo;
 256
 0257            var programId = programInfo.ProgramId ?? string.Empty;
 258
 0259            string newID = programId + "T" + startAt.Ticks + "C" + channelId;
 260
 0261            if (programInfo.AudioProperties.Count != 0)
 262            {
 0263                if (programInfo.AudioProperties.Contains("atmos", StringComparison.OrdinalIgnoreCase))
 264                {
 0265                    audioType = ProgramAudio.Atmos;
 266                }
 0267                else if (programInfo.AudioProperties.Contains("dd 5.1", StringComparison.OrdinalIgnoreCase))
 268                {
 0269                    audioType = ProgramAudio.DolbyDigital;
 270                }
 0271                else if (programInfo.AudioProperties.Contains("dd", StringComparison.OrdinalIgnoreCase))
 272                {
 0273                    audioType = ProgramAudio.DolbyDigital;
 274                }
 0275                else if (programInfo.AudioProperties.Contains("stereo", StringComparison.OrdinalIgnoreCase))
 276                {
 0277                    audioType = ProgramAudio.Stereo;
 278                }
 279                else
 280                {
 0281                    audioType = ProgramAudio.Mono;
 282                }
 283            }
 284
 0285            string episodeTitle = null;
 0286            if (details.EpisodeTitle150 is not null)
 287            {
 0288                episodeTitle = details.EpisodeTitle150;
 289            }
 290
 0291            var info = new ProgramInfo
 0292            {
 0293                ChannelId = channelId,
 0294                Id = newID,
 0295                StartDate = startAt,
 0296                EndDate = endAt,
 0297                Name = details.Titles[0].Title120 ?? "Unknown",
 0298                OfficialRating = null,
 0299                CommunityRating = null,
 0300                EpisodeTitle = episodeTitle,
 0301                Audio = audioType,
 0302                // IsNew = programInfo.@new ?? false,
 0303                IsRepeat = programInfo.New is null,
 0304                IsSeries = string.Equals(details.EntityType, "episode", StringComparison.OrdinalIgnoreCase),
 0305                ImageUrl = details.PrimaryImage,
 0306                ThumbImageUrl = details.ThumbImage,
 0307                IsKids = string.Equals(details.Audience, "children", StringComparison.OrdinalIgnoreCase),
 0308                IsSports = string.Equals(details.EntityType, "sports", StringComparison.OrdinalIgnoreCase),
 0309                IsMovie = IsMovie(details),
 0310                Etag = programInfo.Md5,
 0311                IsLive = string.Equals(programInfo.LiveTapeDelay, "live", StringComparison.OrdinalIgnoreCase),
 0312                IsPremiere = programInfo.Premiere || (programInfo.IsPremiereOrFinale ?? string.Empty).Contains("premiere
 0313            };
 314
 0315            var showId = programId;
 316
 0317            if (!info.IsSeries)
 318            {
 319                // It's also a series if it starts with SH
 0320                info.IsSeries = showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) && showId.Length >= 14;
 321            }
 322
 323            // According to SchedulesDirect, these are generic, unidentified episodes
 324            // SH005316560000
 0325            var hasUniqueShowId = !showId.StartsWith("SH", StringComparison.OrdinalIgnoreCase) ||
 0326                !showId.EndsWith("0000", StringComparison.OrdinalIgnoreCase);
 327
 0328            if (!hasUniqueShowId)
 329            {
 0330                showId = newID;
 331            }
 332
 0333            info.ShowId = showId;
 334
 0335            if (programInfo.VideoProperties is not null)
 336            {
 0337                info.IsHD = programInfo.VideoProperties.Contains("hdtv", StringComparison.OrdinalIgnoreCase);
 0338                info.Is3D = programInfo.VideoProperties.Contains("3d", StringComparison.OrdinalIgnoreCase);
 339            }
 340
 0341            if (details.ContentRating is not null && details.ContentRating.Count > 0)
 342            {
 0343                info.OfficialRating = details.ContentRating[0].Code.Replace("TV", "TV-", StringComparison.Ordinal)
 0344                    .Replace("--", "-", StringComparison.Ordinal);
 345
 0346                var invalid = new[] { "N/A", "Approved", "Not Rated", "Passed" };
 0347                if (invalid.Contains(info.OfficialRating, StringComparison.OrdinalIgnoreCase))
 348                {
 0349                    info.OfficialRating = null;
 350                }
 351            }
 352
 0353            if (details.Descriptions is not null)
 354            {
 0355                if (details.Descriptions.Description1000 is not null && details.Descriptions.Description1000.Count > 0)
 356                {
 0357                    info.Overview = details.Descriptions.Description1000[0].Description;
 358                }
 0359                else if (details.Descriptions.Description100 is not null && details.Descriptions.Description100.Count > 
 360                {
 0361                    info.Overview = details.Descriptions.Description100[0].Description;
 362                }
 363            }
 364
 0365            if (info.IsSeries)
 366            {
 0367                info.SeriesId = programId.Substring(0, 10);
 368
 0369                info.SeriesProviderIds[MetadataProvider.Zap2It.ToString()] = info.SeriesId;
 370
 0371                if (details.Metadata is not null)
 372                {
 0373                    foreach (var metadataProgram in details.Metadata)
 374                    {
 0375                        var gracenote = metadataProgram.Gracenote;
 0376                        if (gracenote is not null)
 377                        {
 0378                            info.SeasonNumber = gracenote.Season;
 379
 0380                            if (gracenote.Episode > 0)
 381                            {
 0382                                info.EpisodeNumber = gracenote.Episode;
 383                            }
 384
 0385                            break;
 386                        }
 387                    }
 388                }
 389            }
 390
 0391            if (details.OriginalAirDate is not null)
 392            {
 0393                info.OriginalAirDate = details.OriginalAirDate;
 0394                info.ProductionYear = info.OriginalAirDate.Value.Year;
 395            }
 396
 0397            if (details.Movie is not null)
 398            {
 0399                if (!string.IsNullOrEmpty(details.Movie.Year)
 0400                    && int.TryParse(details.Movie.Year, out int year))
 401                {
 0402                    info.ProductionYear = year;
 403                }
 404            }
 405
 0406            if (details.Genres is not null)
 407            {
 0408                info.Genres = details.Genres.Where(g => !string.IsNullOrWhiteSpace(g)).ToList();
 0409                info.IsNews = details.Genres.Contains("news", StringComparison.OrdinalIgnoreCase);
 410
 0411                if (info.Genres.Contains("children", StringComparison.OrdinalIgnoreCase))
 412                {
 0413                    info.IsKids = true;
 414                }
 415            }
 416
 0417            return info;
 418        }
 419
 420        private static string GetProgramImage(string apiUrl, IEnumerable<ImageDataDto> images, double desiredAspect, str
 421        {
 0422            var match = images
 0423                .OrderBy(i => Math.Abs(desiredAspect - GetAspectRatio(i)))
 0424                .ThenByDescending(i => GetSizeOrder(i))
 0425                .FirstOrDefault();
 426
 0427            if (match is null)
 428            {
 0429                return null;
 430            }
 431
 0432            var uri = match.Uri;
 433
 0434            if (string.IsNullOrWhiteSpace(uri))
 435            {
 0436                return null;
 437            }
 438
 0439            if (uri.Contains("http", StringComparison.OrdinalIgnoreCase))
 440            {
 0441                return uri;
 442            }
 443
 0444            return apiUrl + "/image/" + uri + "?token=" + token;
 445        }
 446
 447        private static double GetAspectRatio(ImageDataDto i)
 448        {
 0449            int width = 0;
 0450            int height = 0;
 451
 0452            if (!string.IsNullOrWhiteSpace(i.Width))
 453            {
 0454                _ = int.TryParse(i.Width, out width);
 455            }
 456
 0457            if (!string.IsNullOrWhiteSpace(i.Height))
 458            {
 0459                _ = int.TryParse(i.Height, out height);
 460            }
 461
 0462            if (height == 0 || width == 0)
 463            {
 0464                return 0;
 465            }
 466
 0467            double result = width;
 0468            result /= height;
 0469            return result;
 470        }
 471
 472        private async Task<IReadOnlyList<ShowImagesDto>> GetImageForPrograms(
 473            ListingsProviderInfo info,
 474            IReadOnlyList<string> programIds,
 475            CancellationToken cancellationToken)
 476        {
 0477            if (IsImageDailyLimitActive())
 478            {
 0479                return [];
 480            }
 481
 0482            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 483
 0484            if (string.IsNullOrEmpty(token) || programIds.Count == 0)
 485            {
 0486                return [];
 487            }
 488
 489            // SD API accepts max 500 program IDs per request
 490            const int BatchSize = 500;
 0491            var results = new List<ShowImagesDto>();
 0492            for (int i = 0; i < programIds.Count; i += BatchSize)
 493            {
 0494                var batch = programIds.Skip(i).Take(BatchSize);
 495
 0496                using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/");
 0497                message.Headers.TryAddWithoutValidation("token", token);
 0498                message.Content = JsonContent.Create(batch, options: _jsonOptions);
 499
 500                try
 501                {
 0502                    var batchResult = await Request<IReadOnlyList<ShowImagesDto>>(message, true, info, cancellationToken
 0503                    if (batchResult is not null)
 504                    {
 0505                        foreach (var entry in batchResult)
 506                        {
 0507                            if (entry.Code.HasValue)
 508                            {
 0509                                _logger.LogWarning(
 0510                                    "Schedules Direct returned error for program {ProgramId}: code={Code}, message={Mess
 0511                                    entry.ProgramId,
 0512                                    entry.Code,
 0513                                    entry.Message);
 0514                                continue;
 515                            }
 516
 0517                            results.Add(entry);
 518                        }
 519                    }
 0520                }
 0521                catch (Exception ex)
 522                {
 0523                    _logger.LogError(ex, "Error getting image info from schedules direct");
 0524                }
 0525            }
 526
 0527            return results;
 0528        }
 529
 530        public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, Canc
 531        {
 0532            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 533
 0534            var lineups = new List<NameIdPair>();
 535
 0536            if (string.IsNullOrWhiteSpace(token))
 537            {
 0538                return lineups;
 539            }
 540
 0541            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&posta
 0542            options.Headers.TryAddWithoutValidation("token", token);
 543
 544            try
 545            {
 0546                var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureA
 0547                if (root is not null)
 548                {
 0549                    foreach (HeadendsDto headend in root)
 550                    {
 0551                        foreach (LineupDto lineup in headend.Lineups)
 552                        {
 0553                            lineups.Add(new NameIdPair
 0554                            {
 0555                                Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name,
 0556                                Id = lineup.Uri?[18..]
 0557                            });
 558                        }
 559                    }
 560                }
 561                else
 562                {
 0563                    _logger.LogInformation("No lineups available");
 564                }
 0565            }
 0566            catch (Exception ex)
 567            {
 0568                _logger.LogError(ex, "Error getting headends");
 0569            }
 570
 0571            return lineups;
 0572        }
 573
 574        private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken)
 575        {
 0576            var username = info.Username;
 577
 578            // Reset the token if there's no username
 0579            if (string.IsNullOrWhiteSpace(username))
 580            {
 0581                return null;
 582            }
 583
 0584            var password = info.Password;
 0585            if (string.IsNullOrEmpty(password))
 586            {
 0587                return null;
 588            }
 589
 590            // Permanent account error — SD is disabled for this server lifetime.
 0591            if (_accountError)
 592            {
 0593                return null;
 594            }
 595
 596            // Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout)
 0597            if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalM
 598            {
 0599                return null;
 600            }
 601
 0602            if (!_tokens.TryGetValue(username, out NameValuePair savedToken))
 603            {
 0604                savedToken = new NameValuePair();
 0605                _tokens.TryAdd(username, savedToken);
 606            }
 607
 0608            if (!string.IsNullOrEmpty(savedToken.Name)
 0609                && long.TryParse(savedToken.Value, CultureInfo.InvariantCulture, out long ticks))
 610            {
 611                // If it's under 24 hours old we can still use it
 0612                if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks)
 613                {
 0614                    return savedToken.Name;
 615                }
 616            }
 617
 0618            using (await _tokenLock.LockAsync(cancellationToken).ConfigureAwait(false))
 619            {
 620                try
 621                {
 0622                    var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false);
 0623                    savedToken.Name = result;
 0624                    savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture);
 0625                    return result;
 626                }
 0627                catch (HttpRequestException ex)
 628                {
 629                    // For 4xx errors not already handled by Request<T>'s SD code logic
 630                    // (e.g. unparseable response from the /token endpoint), apply a
 631                    // temporary backoff to avoid hammering SD.
 0632                    if (!_accountError
 0633                        && ex.StatusCode.HasValue
 0634                        && (int)ex.StatusCode.Value >= 400
 0635                        && (int)ex.StatusCode.Value < 500)
 636                    {
 0637                        _tokens.Clear();
 0638                        Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
 639                    }
 640
 0641                    throw;
 642                }
 643            }
 0644        }
 645
 646        private async Task<T> Request<T>(
 647            HttpRequestMessage message,
 648            bool enableRetry,
 649            ListingsProviderInfo providerInfo,
 650            CancellationToken cancellationToken,
 651            HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
 652        {
 0653            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 0654                .SendAsync(message, completionOption, cancellationToken)
 0655                .ConfigureAwait(false);
 0656            if (response.IsSuccessStatusCode)
 657            {
 0658                return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken).ConfigureAwait(false
 659            }
 660
 0661            var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 662
 663            // Try to extract the Schedules Direct error code from the response body.
 0664            SdErrorCode? sdCode = null;
 665            try
 666            {
 0667                using var doc = JsonDocument.Parse(responseBody);
 0668                if (doc.RootElement.TryGetProperty("code", out var codeProp)
 0669                    && codeProp.TryGetInt32(out var parsedCode)
 0670                    && Enum.IsDefined((SdErrorCode)parsedCode))
 671                {
 0672                    sdCode = (SdErrorCode)parsedCode;
 673                }
 0674            }
 0675            catch (JsonException)
 676            {
 677                // Response body is not valid JSON; sdCode stays null.
 0678            }
 679
 0680            _logger.LogError(
 0681                "Request to {Url} failed with HTTP {StatusCode}, SD code {SdCode}: {Response}",
 0682                message.RequestUri,
 0683                (int)response.StatusCode,
 0684                sdCode?.ToString() ?? "N/A",
 0685                responseBody);
 686
 0687            if (sdCode is SdErrorCode.AccountExpired or SdErrorCode.InvalidHash or SdErrorCode.InvalidUser or SdErrorCod
 688            {
 689                // Permanent account errors — disable SD for this server lifetime.
 0690                _logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart.", s
 0691                _tokens.Clear();
 0692                _accountError = true;
 693            }
 0694            else if (sdCode is SdErrorCode.ServiceOffline or SdErrorCode.ServiceBusy or SdErrorCode.AccountTempLock)
 695            {
 696                // Transient login errors — back off for 30 minutes, then allow retry.
 0697                _logger.LogError("Schedules Direct transient error (code {SdCode}). Backing off for 30 minutes.", sdCode
 0698                _tokens.Clear();
 0699                Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
 700            }
 0701            else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.MaxIPAttempts)
 702            {
 703                // 24 hour bans - stop image and metadata requests until SD reset at 00:00 UTC.
 0704                _logger.LogError("Schedules Direct service limit error (code {SdCode}). Disabling until SD reset.", sdCo
 0705                SetImageLimitHit();
 0706                SetMetadataLimitHit();
 707            }
 0708            else if (sdCode is SdErrorCode.MaxImageDownloads or SdErrorCode.MaxImageDownloadsTrial)
 709            {
 710                // Max image downloads — stop image requests until SD resets at 00:00 UTC.
 0711                _logger.LogError("Schedules Direct image download limit hit (code {SdCode}). Disabling image acquisition
 0712                SetImageLimitHit();
 713            }
 0714            else if (sdCode is SdErrorCode.MaxScheduleRequests)
 715            {
 716                // Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC.
 0717                _logger.LogError("Schedules Direct metadata download limit hit (code {SdCode}). Disabling metadata acqui
 0718                SetMetadataLimitHit();
 719            }
 0720            else if (enableRetry
 0721                && (int)response.StatusCode < 500
 0722                && (sdCode == SdErrorCode.TokenExpired || (response.StatusCode == HttpStatusCode.Forbidden && sdCode is 
 723            {
 724                // Token expired — clear tokens and retry with a fresh token.
 725                // Also retry on 403 with no parseable SD code (legacy/unexpected auth failure).
 0726                _tokens.Clear();
 0727                using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri);
 0728                retryMessage.Content = message.Content;
 0729                retryMessage.Headers.TryAddWithoutValidation(
 0730                    "token",
 0731                    await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
 732
 0733                return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false);
 734            }
 735
 0736            throw new HttpRequestException(
 0737                string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
 0738                null,
 0739                response.StatusCode);
 0740        }
 741
 742        private async Task<string> GetTokenInternal(
 743            string username,
 744            string password,
 745            CancellationToken cancellationToken)
 746        {
 0747            using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
 748#pragma warning disable CA5350 // SchedulesDirect is always SHA1.
 0749            var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password));
 750#pragma warning restore CA5350
 751            // TODO: remove ToLower when Convert.ToHexString supports lowercase
 752            // Schedules Direct requires the hex to be lowercase
 0753            string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant();
 0754            options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + 
 755
 0756            var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false);
 0757            if (string.Equals(root?.Message, "OK", StringComparison.Ordinal))
 758            {
 0759                _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token);
 0760                return root.Token;
 761            }
 762
 0763            throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message);
 0764        }
 765
 766        private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken)
 767        {
 0768            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 769
 0770            ArgumentException.ThrowIfNullOrEmpty(token);
 0771            ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 772
 0773            _logger.LogInformation("Adding new lineup {Id}", info.ListingsId);
 774
 0775            using var message = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId);
 0776            message.Headers.TryAddWithoutValidation("token", token);
 777
 0778            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 0779                .SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
 0780                .ConfigureAwait(false);
 781
 0782            if (!response.IsSuccessStatusCode)
 783            {
 0784                _logger.LogError(
 0785                    "Error adding lineup to account: {Response}",
 0786                    await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
 787            }
 0788        }
 789
 790        private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken)
 791        {
 0792            ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 793
 0794            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 795
 0796            ArgumentException.ThrowIfNullOrEmpty(token);
 797
 0798            _logger.LogInformation("Headends on account ");
 799
 0800            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups");
 0801            options.Headers.TryAddWithoutValidation("token", token);
 802
 803            try
 804            {
 0805                var root = await Request<LineupsDto>(options, false, null, cancellationToken).ConfigureAwait(false);
 0806                return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCas
 807            }
 808            catch (HttpRequestException ex)
 809            {
 810                // SchedulesDirect returns 400 if no lineups are configured.
 0811                if (ex.StatusCode is HttpStatusCode.BadRequest)
 812                {
 0813                    return false;
 814                }
 815
 0816                throw;
 817            }
 0818        }
 819
 820        /// <inheritdoc />
 821        public async Task<Stream> GetAvailableCountries(CancellationToken cancellationToken)
 822        {
 0823            if (_countriesCache is not null)
 824            {
 0825                return new MemoryStream(_countriesCache, writable: false);
 826            }
 827
 0828            var cachePath = Path.Combine(_appPaths.CachePath, "sd-countries.json");
 829
 0830            if (File.Exists(cachePath)
 0831                && DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath) < TimeSpan.FromDays(CountryCacheDays))
 832            {
 833                try
 834                {
 0835                    _countriesCache = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false);
 0836                    return new MemoryStream(_countriesCache, writable: false);
 837                }
 0838                catch (IOException)
 839                {
 840                    // Corrupt or unreadable — delete and re-fetch.
 0841                    TryDeleteFile(cachePath);
 0842                }
 843            }
 844
 0845            var client = _httpClientFactory.CreateClient(NamedClient.Default);
 0846            using var response = await client.GetAsync(new Uri(ApiUrl + "/available/countries"), cancellationToken).Conf
 0847            response.EnsureSuccessStatusCode();
 848
 0849            var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
 0850            Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
 0851            await File.WriteAllBytesAsync(cachePath, bytes, cancellationToken).ConfigureAwait(false);
 852
 0853            _countriesCache = bytes;
 0854            return new MemoryStream(bytes, writable: false);
 0855        }
 856
 857        private static DateOnly? LoadDailyLimitDate(string path)
 858        {
 42859            if (!File.Exists(path))
 860            {
 42861                return null;
 862            }
 863
 864            try
 865            {
 0866                var text = File.ReadAllText(path).Trim();
 0867                if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date))
 868                {
 0869                    var dateOnly = DateOnly.FromDateTime(date);
 0870                    if (dateOnly < DateOnly.FromDateTime(DateTime.UtcNow))
 871                    {
 872                        // Expired — clean up.
 0873                        File.Delete(path);
 0874                        return null;
 875                    }
 876
 0877                    return dateOnly;
 878                }
 0879            }
 0880            catch (IOException)
 881            {
 882                // Corrupt or unreadable — delete and reset.
 0883                TryDeleteFile(path);
 0884            }
 885
 0886            return null;
 0887        }
 888
 889        /// <inheritdoc />
 890        public bool IsServiceAvailable()
 891        {
 0892            if (_accountError)
 893            {
 0894                return false;
 895            }
 896
 0897            if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalM
 898            {
 0899                return false;
 900            }
 901
 0902            return true;
 903        }
 904
 905        /// <inheritdoc />
 906        public bool IsImageDailyLimitActive()
 907        {
 0908            if (!_imageLimitHitDate.HasValue)
 909            {
 0910                return false;
 911            }
 912
 0913            if (_imageLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow))
 914            {
 0915                _imageLimitHitDate = null;
 0916                TryDeleteFile(ImageLimitFilePath);
 0917                return false;
 918            }
 919
 0920            return true;
 921        }
 922
 923        private bool IsMetadataLimitActive()
 924        {
 0925            if (!_metadataLimitHitDate.HasValue)
 926            {
 0927                return false;
 928            }
 929
 0930            if (_metadataLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow))
 931            {
 0932                _metadataLimitHitDate = null;
 0933                TryDeleteFile(MetadataLimitFilePath);
 0934                return false;
 935            }
 936
 0937            return true;
 938        }
 939
 940        private void SetImageLimitHit()
 941        {
 0942            _imageLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow);
 0943            PersistDailyLimitFile(ImageLimitFilePath);
 0944        }
 945
 946        private void SetMetadataLimitHit()
 947        {
 0948            _metadataLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow);
 0949            PersistDailyLimitFile(MetadataLimitFilePath);
 0950        }
 951
 952        private void PersistDailyLimitFile(string filePath)
 953        {
 954            try
 955            {
 0956                Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
 0957                File.WriteAllText(filePath, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
 0958            }
 0959            catch (IOException ex)
 960            {
 0961                _logger.LogWarning(ex, "Failed to persist SD daily limit to {Path}", filePath);
 0962            }
 0963        }
 964
 965        private static void TryDeleteFile(string path)
 966        {
 967            try
 968            {
 0969                File.Delete(path);
 0970            }
 0971            catch (IOException)
 972            {
 973                // Best effort.
 0974            }
 0975        }
 976
 977        public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
 978        {
 0979            if (validateLogin)
 980            {
 0981                ArgumentException.ThrowIfNullOrEmpty(info.Username);
 0982                ArgumentException.ThrowIfNullOrEmpty(info.Password);
 983            }
 984
 0985            if (validateListings)
 986            {
 0987                ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 988
 0989                var hasLineup = await HasLineup(info, CancellationToken.None).ConfigureAwait(false);
 990
 0991                if (!hasLineup)
 992                {
 0993                    await AddLineupToAccount(info, CancellationToken.None).ConfigureAwait(false);
 994                }
 995            }
 0996        }
 997
 998        public Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location)
 999        {
 01000            return GetHeadends(info, country, location, CancellationToken.None);
 1001        }
 1002
 1003        public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
 1004        {
 01005            var listingsId = info.ListingsId;
 01006            if (string.IsNullOrEmpty(listingsId))
 1007            {
 01008                return [];
 1009            }
 1010
 01011            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 1012
 01013            if (string.IsNullOrEmpty(token))
 1014            {
 01015                return [];
 1016            }
 1017
 01018            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
 01019            options.Headers.TryAddWithoutValidation("token", token);
 1020
 01021            var root = await Request<ChannelDto>(options, true, info, cancellationToken).ConfigureAwait(false);
 01022            if (root is null)
 1023            {
 01024                return new List<ChannelInfo>();
 1025            }
 1026
 01027            _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.Map.Count);
 01028            _logger.LogInformation("Mapping Stations to Channel");
 1029
 01030            var allStations = root.Stations;
 1031
 01032            var map = root.Map;
 01033            var list = new List<ChannelInfo>(map.Count);
 01034            foreach (var channel in map)
 1035            {
 01036                var channelNumber = GetChannelNumber(channel);
 1037
 01038                var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, String
 01039                var station = stationIndex == -1
 01040                    ? new StationDto { StationId = channel.StationId }
 01041                    : allStations[stationIndex];
 1042
 01043                var channelInfo = new ChannelInfo
 01044                {
 01045                    Id = station.StationId,
 01046                    CallSign = station.Callsign,
 01047                    Number = channelNumber,
 01048                    Name = string.IsNullOrWhiteSpace(station.Name) ? channelNumber : station.Name
 01049                };
 1050
 01051                if (station.Logo is not null)
 1052                {
 01053                    channelInfo.ImageUrl = station.Logo.Url;
 1054                }
 1055
 01056                list.Add(channelInfo);
 1057            }
 1058
 01059            return list;
 01060        }
 1061
 1062        /// <inheritdoc />
 1063        public void Dispose()
 1064        {
 631065            Dispose(true);
 631066            GC.SuppressFinalize(this);
 631067        }
 1068
 1069        /// <summary>
 1070        /// Releases unmanaged and optionally managed resources.
 1071        /// </summary>
 1072        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release
 1073        protected virtual void Dispose(bool disposing)
 1074        {
 631075            if (_disposed)
 1076            {
 421077                return;
 1078            }
 1079
 211080            if (disposing)
 1081            {
 211082                _tokenLock?.Dispose();
 1083            }
 1084
 211085            _disposed = true;
 211086        }
 1087    }
 1088}