< 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: 483
Coverable lines: 505
Total lines: 1104
Line coverage: 4.3%
Branch coverage
1%
Covered branches: 5
Total branches: 305
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 5/1/2026 - 12:13:05 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: 10886/28/2026 - 12:15:35 AM Line coverage: 4.4% (22/499) Branch coverage: 1.6% (5/297) Total lines: 10867/21/2026 - 12:16:33 AM Line coverage: 4.3% (22/505) Branch coverage: 1.6% (5/305) Total lines: 1104 5/1/2026 - 12:13:05 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: 10886/28/2026 - 12:15:35 AM Line coverage: 4.4% (22/499) Branch coverage: 1.6% (5/297) Total lines: 10867/21/2026 - 12:16:33 AM Line coverage: 4.3% (22/505) Branch coverage: 1.6% (5/305) Total lines: 1104

Coverage delta

Coverage delta 1 -1

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;
 2244        private readonly AsyncNonKeyedLocker _tokenLock = new(1);
 45
 2246        private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new();
 2247        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        {
 2261            _logger = logger;
 2262            _httpClientFactory = httpClientFactory;
 2263            _appPaths = appPaths;
 2264            _imageLimitHitDate = LoadDailyLimitDate(ImageLimitFilePath);
 2265            _metadataLimitHitDate = LoadDailyLimitDate(MetadataLimitFilePath);
 2266        }
 67
 68        /// <inheritdoc />
 069        public string Name => "Schedules Direct";
 70
 2271        private string ImageLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-image-limit.txt");
 72
 2273        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            {
 494                // The daily image limit may be surfaced mid-batch.
 0495                if (IsImageDailyLimitActive())
 496                {
 497                    break;
 498                }
 499
 0500                var batch = programIds.Skip(i).Take(BatchSize);
 501
 0502                using var message = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/metadata/programs/");
 0503                message.Headers.TryAddWithoutValidation("token", token);
 0504                message.Content = JsonContent.Create(batch, options: _jsonOptions);
 505
 506                try
 507                {
 0508                    var batchResult = await Request<IReadOnlyList<ShowImagesDto>>(message, true, info, cancellationToken
 0509                    if (batchResult is not null)
 510                    {
 0511                        foreach (var entry in batchResult)
 512                        {
 0513                            if (entry.Code.HasValue)
 514                            {
 0515                                _logger.LogWarning(
 0516                                    "Schedules Direct returned error for program {ProgramId}: code={Code}, message={Mess
 0517                                    entry.ProgramId,
 0518                                    entry.Code,
 0519                                    entry.Message);
 520
 521                                // The image download limit can be reported per-entry inside an
 522                                // otherwise successful (HTTP 200) response when the limit is hit
 523                                // mid-batch. Back off so we stop requesting images until SD resets.
 0524                                if (entry.Code is (int)SdErrorCode.MaxImageDownloads or (int)SdErrorCode.MaxImageDownloa
 525                                {
 0526                                    _logger.LogError(
 0527                                        "Schedules Direct image download limit hit (code {Code}). Disabling image acquis
 0528                                        entry.Code);
 0529                                    SetImageLimitHit();
 530                                }
 531
 0532                                continue;
 533                            }
 534
 0535                            results.Add(entry);
 536                        }
 537                    }
 0538                }
 0539                catch (Exception ex)
 540                {
 0541                    _logger.LogError(ex, "Error getting image info from schedules direct");
 0542                }
 0543            }
 544
 0545            return results;
 0546        }
 547
 548        public async Task<List<NameIdPair>> GetHeadends(ListingsProviderInfo info, string country, string location, Canc
 549        {
 0550            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 551
 0552            var lineups = new List<NameIdPair>();
 553
 0554            if (string.IsNullOrWhiteSpace(token))
 555            {
 0556                return lineups;
 557            }
 558
 0559            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&posta
 0560            options.Headers.TryAddWithoutValidation("token", token);
 561
 562            try
 563            {
 0564                var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureA
 0565                if (root is not null)
 566                {
 0567                    foreach (HeadendsDto headend in root)
 568                    {
 0569                        foreach (LineupDto lineup in headend.Lineups)
 570                        {
 0571                            lineups.Add(new NameIdPair
 0572                            {
 0573                                Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name,
 0574                                Id = lineup.Uri?[18..]
 0575                            });
 576                        }
 577                    }
 578                }
 579                else
 580                {
 0581                    _logger.LogInformation("No lineups available");
 582                }
 0583            }
 0584            catch (Exception ex)
 585            {
 0586                _logger.LogError(ex, "Error getting headends");
 0587            }
 588
 0589            return lineups;
 0590        }
 591
 592        private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken)
 593        {
 0594            var username = info.Username;
 595
 596            // Reset the token if there's no username
 0597            if (string.IsNullOrWhiteSpace(username))
 598            {
 0599                return null;
 600            }
 601
 0602            var password = info.Password;
 0603            if (string.IsNullOrEmpty(password))
 604            {
 0605                return null;
 606            }
 607
 608            // Permanent account error — SD is disabled for this server lifetime.
 0609            if (_accountError)
 610            {
 0611                return null;
 612            }
 613
 614            // Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout)
 0615            if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalM
 616            {
 0617                return null;
 618            }
 619
 0620            if (!_tokens.TryGetValue(username, out NameValuePair savedToken))
 621            {
 0622                savedToken = new NameValuePair();
 0623                _tokens.TryAdd(username, savedToken);
 624            }
 625
 0626            if (!string.IsNullOrEmpty(savedToken.Name)
 0627                && long.TryParse(savedToken.Value, CultureInfo.InvariantCulture, out long ticks))
 628            {
 629                // If it's under 24 hours old we can still use it
 0630                if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks)
 631                {
 0632                    return savedToken.Name;
 633                }
 634            }
 635
 0636            using (await _tokenLock.LockAsync(cancellationToken).ConfigureAwait(false))
 637            {
 638                try
 639                {
 0640                    var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false);
 0641                    savedToken.Name = result;
 0642                    savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture);
 0643                    return result;
 644                }
 0645                catch (HttpRequestException ex)
 646                {
 647                    // For 4xx errors not already handled by Request<T>'s SD code logic
 648                    // (e.g. unparseable response from the /token endpoint), apply a
 649                    // temporary backoff to avoid hammering SD.
 0650                    if (!_accountError
 0651                        && ex.StatusCode.HasValue
 0652                        && (int)ex.StatusCode.Value >= 400
 0653                        && (int)ex.StatusCode.Value < 500)
 654                    {
 0655                        _tokens.Clear();
 0656                        Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
 657                    }
 658
 0659                    throw;
 660                }
 661            }
 0662        }
 663
 664        private async Task<T> Request<T>(
 665            HttpRequestMessage message,
 666            bool enableRetry,
 667            ListingsProviderInfo providerInfo,
 668            CancellationToken cancellationToken,
 669            HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
 670        {
 0671            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 0672                .SendAsync(message, completionOption, cancellationToken)
 0673                .ConfigureAwait(false);
 0674            if (response.IsSuccessStatusCode)
 675            {
 0676                return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken).ConfigureAwait(false
 677            }
 678
 0679            var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 680
 681            // Try to extract the Schedules Direct error code from the response body.
 0682            SdErrorCode? sdCode = null;
 683            try
 684            {
 0685                using var doc = JsonDocument.Parse(responseBody);
 0686                if (doc.RootElement.TryGetProperty("code", out var codeProp)
 0687                    && codeProp.TryGetInt32(out var parsedCode)
 0688                    && Enum.IsDefined((SdErrorCode)parsedCode))
 689                {
 0690                    sdCode = (SdErrorCode)parsedCode;
 691                }
 0692            }
 0693            catch (JsonException)
 694            {
 695                // Response body is not valid JSON; sdCode stays null.
 0696            }
 697
 0698            _logger.LogError(
 0699                "Request to {Url} failed with HTTP {StatusCode}, SD code {SdCode}: {Response}",
 0700                message.RequestUri,
 0701                (int)response.StatusCode,
 0702                sdCode?.ToString() ?? "N/A",
 0703                responseBody);
 704
 0705            if (sdCode is SdErrorCode.AccountExpired or SdErrorCode.InvalidHash or SdErrorCode.InvalidUser or SdErrorCod
 706            {
 707                // Permanent account errors — disable SD for this server lifetime.
 0708                _logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart.", s
 0709                _tokens.Clear();
 0710                _accountError = true;
 711            }
 0712            else if (sdCode is SdErrorCode.ServiceOffline or SdErrorCode.ServiceBusy or SdErrorCode.AccountTempLock)
 713            {
 714                // Transient login errors — back off for 30 minutes, then allow retry.
 0715                _logger.LogError("Schedules Direct transient error (code {SdCode}). Backing off for 30 minutes.", sdCode
 0716                _tokens.Clear();
 0717                Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
 718            }
 0719            else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.MaxIPAttempts)
 720            {
 721                // 24 hour bans - stop image and metadata requests until SD reset at 00:00 UTC.
 0722                _logger.LogError("Schedules Direct service limit error (code {SdCode}). Disabling until SD reset.", sdCo
 0723                SetImageLimitHit();
 0724                SetMetadataLimitHit();
 725            }
 0726            else if (sdCode is SdErrorCode.MaxImageDownloads or SdErrorCode.MaxImageDownloadsTrial)
 727            {
 728                // Max image downloads — stop image requests until SD resets at 00:00 UTC.
 0729                _logger.LogError("Schedules Direct image download limit hit (code {SdCode}). Disabling image acquisition
 0730                SetImageLimitHit();
 731            }
 0732            else if (sdCode is SdErrorCode.MaxScheduleRequests)
 733            {
 734                // Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC.
 0735                _logger.LogError("Schedules Direct metadata download limit hit (code {SdCode}). Disabling metadata acqui
 0736                SetMetadataLimitHit();
 737            }
 0738            else if (enableRetry
 0739                && (int)response.StatusCode < 500
 0740                && (sdCode == SdErrorCode.TokenExpired || (response.StatusCode == HttpStatusCode.Forbidden && sdCode is 
 741            {
 742                // Token expired — clear tokens and retry with a fresh token.
 743                // Also retry on 403 with no parseable SD code (legacy/unexpected auth failure).
 0744                _tokens.Clear();
 0745                using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri);
 0746                retryMessage.Content = message.Content;
 0747                retryMessage.Headers.TryAddWithoutValidation(
 0748                    "token",
 0749                    await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
 750
 0751                return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false);
 752            }
 753
 0754            throw new HttpRequestException(
 0755                string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
 0756                null,
 0757                response.StatusCode);
 0758        }
 759
 760        private async Task<string> GetTokenInternal(
 761            string username,
 762            string password,
 763            CancellationToken cancellationToken)
 764        {
 0765            using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
 766#pragma warning disable CA5350 // SchedulesDirect is always SHA1.
 0767            var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password));
 768#pragma warning restore CA5350
 0769            string hashedPassword = Convert.ToHexStringLower(hashedPasswordBytes);
 0770            options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + 
 771
 0772            var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false);
 0773            if (string.Equals(root?.Message, "OK", StringComparison.Ordinal))
 774            {
 0775                _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token);
 0776                return root.Token;
 777            }
 778
 0779            throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + root.Message);
 0780        }
 781
 782        private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken)
 783        {
 0784            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 785
 0786            ArgumentException.ThrowIfNullOrEmpty(token);
 0787            ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 788
 0789            _logger.LogInformation("Adding new lineup {Id}", info.ListingsId);
 790
 0791            using var message = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId);
 0792            message.Headers.TryAddWithoutValidation("token", token);
 793
 0794            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 0795                .SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
 0796                .ConfigureAwait(false);
 797
 0798            if (!response.IsSuccessStatusCode)
 799            {
 0800                _logger.LogError(
 0801                    "Error adding lineup to account: {Response}",
 0802                    await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
 803            }
 0804        }
 805
 806        private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken)
 807        {
 0808            ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 809
 0810            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 811
 0812            ArgumentException.ThrowIfNullOrEmpty(token);
 813
 0814            _logger.LogInformation("Headends on account ");
 815
 0816            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups");
 0817            options.Headers.TryAddWithoutValidation("token", token);
 818
 819            try
 820            {
 0821                var root = await Request<LineupsDto>(options, false, null, cancellationToken).ConfigureAwait(false);
 0822                return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCas
 823            }
 824            catch (HttpRequestException ex)
 825            {
 826                // SchedulesDirect returns 400 if no lineups are configured.
 0827                if (ex.StatusCode is HttpStatusCode.BadRequest)
 828                {
 0829                    return false;
 830                }
 831
 0832                throw;
 833            }
 0834        }
 835
 836        /// <inheritdoc />
 837        public async Task<Stream> GetAvailableCountries(CancellationToken cancellationToken)
 838        {
 0839            if (_countriesCache is not null)
 840            {
 0841                return new MemoryStream(_countriesCache, writable: false);
 842            }
 843
 0844            var cachePath = Path.Combine(_appPaths.CachePath, "sd-countries.json");
 845
 0846            if (File.Exists(cachePath)
 0847                && DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath) < TimeSpan.FromDays(CountryCacheDays))
 848            {
 849                try
 850                {
 0851                    _countriesCache = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false);
 0852                    return new MemoryStream(_countriesCache, writable: false);
 853                }
 0854                catch (IOException)
 855                {
 856                    // Corrupt or unreadable — delete and re-fetch.
 0857                    TryDeleteFile(cachePath);
 0858                }
 859            }
 860
 0861            var client = _httpClientFactory.CreateClient(NamedClient.Default);
 0862            using var response = await client.GetAsync(new Uri(ApiUrl + "/available/countries"), cancellationToken).Conf
 0863            response.EnsureSuccessStatusCode();
 864
 0865            var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
 0866            Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
 0867            await File.WriteAllBytesAsync(cachePath, bytes, cancellationToken).ConfigureAwait(false);
 868
 0869            _countriesCache = bytes;
 0870            return new MemoryStream(bytes, writable: false);
 0871        }
 872
 873        private static DateOnly? LoadDailyLimitDate(string path)
 874        {
 44875            if (!File.Exists(path))
 876            {
 44877                return null;
 878            }
 879
 880            try
 881            {
 0882                var text = File.ReadAllText(path).Trim();
 0883                if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date))
 884                {
 0885                    var dateOnly = DateOnly.FromDateTime(date);
 0886                    if (dateOnly < DateOnly.FromDateTime(DateTime.UtcNow))
 887                    {
 888                        // Expired — clean up.
 0889                        File.Delete(path);
 0890                        return null;
 891                    }
 892
 0893                    return dateOnly;
 894                }
 0895            }
 0896            catch (IOException)
 897            {
 898                // Corrupt or unreadable — delete and reset.
 0899                TryDeleteFile(path);
 0900            }
 901
 0902            return null;
 0903        }
 904
 905        /// <inheritdoc />
 906        public bool IsServiceAvailable()
 907        {
 0908            if (_accountError)
 909            {
 0910                return false;
 911            }
 912
 0913            if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalM
 914            {
 0915                return false;
 916            }
 917
 0918            return true;
 919        }
 920
 921        /// <inheritdoc />
 922        public bool IsImageDailyLimitActive()
 923        {
 0924            if (!_imageLimitHitDate.HasValue)
 925            {
 0926                return false;
 927            }
 928
 0929            if (_imageLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow))
 930            {
 0931                _imageLimitHitDate = null;
 0932                TryDeleteFile(ImageLimitFilePath);
 0933                return false;
 934            }
 935
 0936            return true;
 937        }
 938
 939        private bool IsMetadataLimitActive()
 940        {
 0941            if (!_metadataLimitHitDate.HasValue)
 942            {
 0943                return false;
 944            }
 945
 0946            if (_metadataLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow))
 947            {
 0948                _metadataLimitHitDate = null;
 0949                TryDeleteFile(MetadataLimitFilePath);
 0950                return false;
 951            }
 952
 0953            return true;
 954        }
 955
 956        private void SetImageLimitHit()
 957        {
 0958            _imageLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow);
 0959            PersistDailyLimitFile(ImageLimitFilePath);
 0960        }
 961
 962        private void SetMetadataLimitHit()
 963        {
 0964            _metadataLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow);
 0965            PersistDailyLimitFile(MetadataLimitFilePath);
 0966        }
 967
 968        private void PersistDailyLimitFile(string filePath)
 969        {
 970            try
 971            {
 0972                Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
 0973                File.WriteAllText(filePath, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
 0974            }
 0975            catch (IOException ex)
 976            {
 0977                _logger.LogWarning(ex, "Failed to persist SD daily limit to {Path}", filePath);
 0978            }
 0979        }
 980
 981        private static void TryDeleteFile(string path)
 982        {
 983            try
 984            {
 0985                File.Delete(path);
 0986            }
 0987            catch (IOException)
 988            {
 989                // Best effort.
 0990            }
 0991        }
 992
 993        public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
 994        {
 0995            if (validateLogin)
 996            {
 0997                ArgumentException.ThrowIfNullOrEmpty(info.Username);
 0998                ArgumentException.ThrowIfNullOrEmpty(info.Password);
 999            }
 1000
 01001            if (validateListings)
 1002            {
 01003                ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 1004
 01005                var hasLineup = await HasLineup(info, CancellationToken.None).ConfigureAwait(false);
 1006
 01007                if (!hasLineup)
 1008                {
 01009                    await AddLineupToAccount(info, CancellationToken.None).ConfigureAwait(false);
 1010                }
 1011            }
 01012        }
 1013
 1014        public Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location)
 1015        {
 01016            return GetHeadends(info, country, location, CancellationToken.None);
 1017        }
 1018
 1019        public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
 1020        {
 01021            var listingsId = info.ListingsId;
 01022            if (string.IsNullOrEmpty(listingsId))
 1023            {
 01024                return [];
 1025            }
 1026
 01027            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 1028
 01029            if (string.IsNullOrEmpty(token))
 1030            {
 01031                return [];
 1032            }
 1033
 01034            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
 01035            options.Headers.TryAddWithoutValidation("token", token);
 1036
 01037            var root = await Request<ChannelDto>(options, true, info, cancellationToken).ConfigureAwait(false);
 01038            if (root is null)
 1039            {
 01040                return new List<ChannelInfo>();
 1041            }
 1042
 01043            _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.Map.Count);
 01044            _logger.LogInformation("Mapping Stations to Channel");
 1045
 01046            var allStations = root.Stations;
 1047
 01048            var map = root.Map;
 01049            var list = new List<ChannelInfo>(map.Count);
 01050            foreach (var channel in map)
 1051            {
 01052                var channelNumber = GetChannelNumber(channel);
 1053
 01054                var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, String
 01055                var station = stationIndex == -1
 01056                    ? new StationDto { StationId = channel.StationId }
 01057                    : allStations[stationIndex];
 1058
 01059                var channelInfo = new ChannelInfo
 01060                {
 01061                    Id = station.StationId,
 01062                    CallSign = station.Callsign,
 01063                    Number = channelNumber,
 01064                    Name = string.IsNullOrWhiteSpace(station.Name) ? channelNumber : station.Name
 01065                };
 1066
 01067                if (station.Logo is not null)
 1068                {
 01069                    channelInfo.ImageUrl = station.Logo.Url;
 1070                }
 1071
 01072                list.Add(channelInfo);
 1073            }
 1074
 01075            return list;
 01076        }
 1077
 1078        /// <inheritdoc />
 1079        public void Dispose()
 1080        {
 661081            Dispose(true);
 661082            GC.SuppressFinalize(this);
 661083        }
 1084
 1085        /// <summary>
 1086        /// Releases unmanaged and optionally managed resources.
 1087        /// </summary>
 1088        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release
 1089        protected virtual void Dispose(bool disposing)
 1090        {
 661091            if (_disposed)
 1092            {
 441093                return;
 1094            }
 1095
 221096            if (disposing)
 1097            {
 221098                _tokenLock?.Dispose();
 1099            }
 1100
 221101            _disposed = true;
 221102        }
 1103    }
 1104}