< 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: 470
Coverable lines: 492
Total lines: 1078
Line coverage: 4.4%
Branch coverage
2%
Covered branches: 6
Total branches: 287
Branch coverage: 2%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 1/23/2026 - 12:11:06 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: 1078 1/23/2026 - 12:11:06 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: 1078

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.InvalidUser or SdErrorCode.InvalidHash or SdErrorCode.AccountLocked or SdErrorCode
 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", sd
 0691                _tokens.Clear();
 0692                _accountError = true;
 693            }
 0694            else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.TemporaryLockout)
 695            {
 696                // Transient login errors — back off for 30 minutes, then allow retry.
 0697                _tokens.Clear();
 0698                Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
 699            }
 0700            else if (sdCode is SdErrorCode.MaxImageDownloads)
 701            {
 702                // Max image downloads — stop image requests until SD resets at 00:00 UTC.
 0703                SetImageLimitHit();
 704            }
 0705            else if (sdCode is SdErrorCode.MaxScheduleRequests)
 706            {
 707                // Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC.
 0708                SetMetadataLimitHit();
 709            }
 0710            else if (enableRetry
 0711                && (int)response.StatusCode < 500
 0712                && (sdCode == SdErrorCode.TokenExpired || (response.StatusCode == HttpStatusCode.Forbidden && sdCode is 
 713            {
 714                // Token expired — clear tokens and retry with a fresh token.
 715                // Also retry on 403 with no parseable SD code (legacy/unexpected auth failure).
 0716                _tokens.Clear();
 0717                using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri);
 0718                retryMessage.Content = message.Content;
 0719                retryMessage.Headers.TryAddWithoutValidation(
 0720                    "token",
 0721                    await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
 722
 0723                return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false);
 724            }
 725
 0726            throw new HttpRequestException(
 0727                string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
 0728                null,
 0729                response.StatusCode);
 0730        }
 731
 732        private async Task<string> GetTokenInternal(
 733            string username,
 734            string password,
 735            CancellationToken cancellationToken)
 736        {
 0737            using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
 738#pragma warning disable CA5350 // SchedulesDirect is always SHA1.
 0739            var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password));
 740#pragma warning restore CA5350
 741            // TODO: remove ToLower when Convert.ToHexString supports lowercase
 742            // Schedules Direct requires the hex to be lowercase
 0743            string hashedPassword = Convert.ToHexString(hashedPasswordBytes).ToLowerInvariant();
 0744            options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + 
 745
 0746            var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false);
 0747            if (string.Equals(root?.Message, "OK", StringComparison.Ordinal))
 748            {
 0749                _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token);
 0750                return root.Token;
 751            }
 752
 0753            throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message);
 0754        }
 755
 756        private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken)
 757        {
 0758            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 759
 0760            ArgumentException.ThrowIfNullOrEmpty(token);
 0761            ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 762
 0763            _logger.LogInformation("Adding new lineup {Id}", info.ListingsId);
 764
 0765            using var message = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId);
 0766            message.Headers.TryAddWithoutValidation("token", token);
 767
 0768            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 0769                .SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
 0770                .ConfigureAwait(false);
 771
 0772            if (!response.IsSuccessStatusCode)
 773            {
 0774                _logger.LogError(
 0775                    "Error adding lineup to account: {Response}",
 0776                    await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
 777            }
 0778        }
 779
 780        private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken)
 781        {
 0782            ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 783
 0784            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 785
 0786            ArgumentException.ThrowIfNullOrEmpty(token);
 787
 0788            _logger.LogInformation("Headends on account ");
 789
 0790            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups");
 0791            options.Headers.TryAddWithoutValidation("token", token);
 792
 793            try
 794            {
 0795                var root = await Request<LineupsDto>(options, false, null, cancellationToken).ConfigureAwait(false);
 0796                return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCas
 797            }
 798            catch (HttpRequestException ex)
 799            {
 800                // SchedulesDirect returns 400 if no lineups are configured.
 0801                if (ex.StatusCode is HttpStatusCode.BadRequest)
 802                {
 0803                    return false;
 804                }
 805
 0806                throw;
 807            }
 0808        }
 809
 810        /// <inheritdoc />
 811        public async Task<Stream> GetAvailableCountries(CancellationToken cancellationToken)
 812        {
 0813            if (_countriesCache is not null)
 814            {
 0815                return new MemoryStream(_countriesCache, writable: false);
 816            }
 817
 0818            var cachePath = Path.Combine(_appPaths.CachePath, "sd-countries.json");
 819
 0820            if (File.Exists(cachePath)
 0821                && DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath) < TimeSpan.FromDays(CountryCacheDays))
 822            {
 823                try
 824                {
 0825                    _countriesCache = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false);
 0826                    return new MemoryStream(_countriesCache, writable: false);
 827                }
 0828                catch (IOException)
 829                {
 830                    // Corrupt or unreadable — delete and re-fetch.
 0831                    TryDeleteFile(cachePath);
 0832                }
 833            }
 834
 0835            var client = _httpClientFactory.CreateClient(NamedClient.Default);
 0836            using var response = await client.GetAsync(new Uri(ApiUrl + "/available/countries"), cancellationToken).Conf
 0837            response.EnsureSuccessStatusCode();
 838
 0839            var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
 0840            Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
 0841            await File.WriteAllBytesAsync(cachePath, bytes, cancellationToken).ConfigureAwait(false);
 842
 0843            _countriesCache = bytes;
 0844            return new MemoryStream(bytes, writable: false);
 0845        }
 846
 847        private static DateOnly? LoadDailyLimitDate(string path)
 848        {
 42849            if (!File.Exists(path))
 850            {
 42851                return null;
 852            }
 853
 854            try
 855            {
 0856                var text = File.ReadAllText(path).Trim();
 0857                if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date))
 858                {
 0859                    var dateOnly = DateOnly.FromDateTime(date);
 0860                    if (dateOnly < DateOnly.FromDateTime(DateTime.UtcNow))
 861                    {
 862                        // Expired — clean up.
 0863                        File.Delete(path);
 0864                        return null;
 865                    }
 866
 0867                    return dateOnly;
 868                }
 0869            }
 0870            catch (IOException)
 871            {
 872                // Corrupt or unreadable — delete and reset.
 0873                TryDeleteFile(path);
 0874            }
 875
 0876            return null;
 0877        }
 878
 879        /// <inheritdoc />
 880        public bool IsServiceAvailable()
 881        {
 0882            if (_accountError)
 883            {
 0884                return false;
 885            }
 886
 0887            if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalM
 888            {
 0889                return false;
 890            }
 891
 0892            return true;
 893        }
 894
 895        /// <inheritdoc />
 896        public bool IsImageDailyLimitActive()
 897        {
 0898            if (!_imageLimitHitDate.HasValue)
 899            {
 0900                return false;
 901            }
 902
 0903            if (_imageLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow))
 904            {
 0905                _imageLimitHitDate = null;
 0906                TryDeleteFile(ImageLimitFilePath);
 0907                return false;
 908            }
 909
 0910            return true;
 911        }
 912
 913        private bool IsMetadataLimitActive()
 914        {
 0915            if (!_metadataLimitHitDate.HasValue)
 916            {
 0917                return false;
 918            }
 919
 0920            if (_metadataLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow))
 921            {
 0922                _metadataLimitHitDate = null;
 0923                TryDeleteFile(MetadataLimitFilePath);
 0924                return false;
 925            }
 926
 0927            return true;
 928        }
 929
 930        private void SetImageLimitHit()
 931        {
 0932            _imageLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow);
 0933            PersistDailyLimitFile(ImageLimitFilePath);
 0934        }
 935
 936        private void SetMetadataLimitHit()
 937        {
 0938            _metadataLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow);
 0939            PersistDailyLimitFile(MetadataLimitFilePath);
 0940        }
 941
 942        private void PersistDailyLimitFile(string filePath)
 943        {
 944            try
 945            {
 0946                Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
 0947                File.WriteAllText(filePath, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
 0948            }
 0949            catch (IOException ex)
 950            {
 0951                _logger.LogWarning(ex, "Failed to persist SD daily limit to {Path}", filePath);
 0952            }
 0953        }
 954
 955        private static void TryDeleteFile(string path)
 956        {
 957            try
 958            {
 0959                File.Delete(path);
 0960            }
 0961            catch (IOException)
 962            {
 963                // Best effort.
 0964            }
 0965        }
 966
 967        public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
 968        {
 0969            if (validateLogin)
 970            {
 0971                ArgumentException.ThrowIfNullOrEmpty(info.Username);
 0972                ArgumentException.ThrowIfNullOrEmpty(info.Password);
 973            }
 974
 0975            if (validateListings)
 976            {
 0977                ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 978
 0979                var hasLineup = await HasLineup(info, CancellationToken.None).ConfigureAwait(false);
 980
 0981                if (!hasLineup)
 982                {
 0983                    await AddLineupToAccount(info, CancellationToken.None).ConfigureAwait(false);
 984                }
 985            }
 0986        }
 987
 988        public Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location)
 989        {
 0990            return GetHeadends(info, country, location, CancellationToken.None);
 991        }
 992
 993        public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
 994        {
 0995            var listingsId = info.ListingsId;
 0996            if (string.IsNullOrEmpty(listingsId))
 997            {
 0998                return [];
 999            }
 1000
 01001            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 1002
 01003            if (string.IsNullOrEmpty(token))
 1004            {
 01005                return [];
 1006            }
 1007
 01008            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
 01009            options.Headers.TryAddWithoutValidation("token", token);
 1010
 01011            var root = await Request<ChannelDto>(options, true, info, cancellationToken).ConfigureAwait(false);
 01012            if (root is null)
 1013            {
 01014                return new List<ChannelInfo>();
 1015            }
 1016
 01017            _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.Map.Count);
 01018            _logger.LogInformation("Mapping Stations to Channel");
 1019
 01020            var allStations = root.Stations;
 1021
 01022            var map = root.Map;
 01023            var list = new List<ChannelInfo>(map.Count);
 01024            foreach (var channel in map)
 1025            {
 01026                var channelNumber = GetChannelNumber(channel);
 1027
 01028                var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, String
 01029                var station = stationIndex == -1
 01030                    ? new StationDto { StationId = channel.StationId }
 01031                    : allStations[stationIndex];
 1032
 01033                var channelInfo = new ChannelInfo
 01034                {
 01035                    Id = station.StationId,
 01036                    CallSign = station.Callsign,
 01037                    Number = channelNumber,
 01038                    Name = string.IsNullOrWhiteSpace(station.Name) ? channelNumber : station.Name
 01039                };
 1040
 01041                if (station.Logo is not null)
 1042                {
 01043                    channelInfo.ImageUrl = station.Logo.Url;
 1044                }
 1045
 01046                list.Add(channelInfo);
 1047            }
 1048
 01049            return list;
 01050        }
 1051
 1052        /// <inheritdoc />
 1053        public void Dispose()
 1054        {
 631055            Dispose(true);
 631056            GC.SuppressFinalize(this);
 631057        }
 1058
 1059        /// <summary>
 1060        /// Releases unmanaged and optionally managed resources.
 1061        /// </summary>
 1062        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release
 1063        protected virtual void Dispose(bool disposing)
 1064        {
 631065            if (_disposed)
 1066            {
 421067                return;
 1068            }
 1069
 211070            if (disposing)
 1071            {
 211072                _tokenLock?.Dispose();
 1073            }
 1074
 211075            _disposed = true;
 211076        }
 1077    }
 1078}