< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.Listings.SchedulesDirect
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/Listings/SchedulesDirect.cs
Line coverage
22%
Covered lines: 114
Uncovered lines: 401
Coverable lines: 515
Total lines: 1122
Line coverage: 22.1%
Branch coverage
19%
Covered branches: 63
Total branches: 319
Branch coverage: 19.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/16/2026 - 12:15:55 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: 110410/29/2026 - 12:17:11 AM Line coverage: 22.1% (114/515) Branch coverage: 19.7% (63/319) Total lines: 1122 5/16/2026 - 12:15:55 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: 110410/29/2026 - 12:17:11 AM Line coverage: 22.1% (114/515) Branch coverage: 19.7% (63/319) Total lines: 1122

Coverage delta

Coverage delta 19 -19

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
get_Name()100%210%
get_ImageLimitFilePath()100%11100%
get_MetadataLimitFilePath()100%11100%
get_Type()100%210%
GetScheduleRequestDates(...)0%620%
GetProgramsAsync()0%930300%
GetSizeOrder(...)0%620%
GetChannelNumber(...)0%2040%
IsMovie(...)100%210%
GetProgram(...)0%4970700%
GetProgramImage(...)0%4260%
GetAspectRatio(...)0%7280%
GetImageForPrograms()0%506220%
GetHeadends()55.55%221876.19%
ResetErrorState(...)50%22100%
GetToken()66.66%322475.75%
Request()39.34%4766151.85%
GetTokenInternal()25%8890%
AddLineupToAccount()0%2040%
HasLineup()0%4260%
GetAvailableCountries()0%4260%
LoadDailyLimitDate(...)16.66%29613.33%
IsServiceAvailable()0%2040%
IsImageDailyLimitActive()0%2040%
IsMetadataLimitActive()0%2040%
SetImageLimitHit()100%210%
SetMetadataLimitHit()100%210%
PersistDailyLimitFile(...)100%210%
TryDeleteFile(...)100%210%
Validate()62.5%12861.53%
GetLineups(...)100%11100%
GetChannels()0%210140%
Dispose()100%11100%
Dispose(...)66.66%66100%

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;
 3144        private readonly AsyncNonKeyedLocker _tokenLock = new(1);
 45
 3146        private readonly ConcurrentDictionary<string, NameValuePair> _tokens = new();
 3147        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        {
 3161            _logger = logger;
 3162            _httpClientFactory = httpClientFactory;
 3163            _appPaths = appPaths;
 3164            _imageLimitHitDate = LoadDailyLimitDate(ImageLimitFilePath);
 3165            _metadataLimitHitDate = LoadDailyLimitDate(MetadataLimitFilePath);
 3166        }
 67
 68        /// <inheritdoc />
 069        public string Name => "Schedules Direct";
 70
 3171        private string ImageLimitFilePath => Path.Combine(_appPaths.CachePath, "sd-image-limit.txt");
 72
 3173        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        {
 4550            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 551
 2552            if (string.IsNullOrWhiteSpace(token))
 553            {
 0554                throw new AuthenticationException("Could not authenticate with Schedules Direct");
 555            }
 556
 2557            var lineups = new List<NameIdPair>();
 558
 2559            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/headends?country=" + country + "&posta
 2560            options.Headers.TryAddWithoutValidation("token", token);
 561
 2562            var root = await Request<IReadOnlyList<HeadendsDto>>(options, false, info, cancellationToken).ConfigureAwait
 36563            foreach (HeadendsDto headend in root ?? [])
 564            {
 76565                foreach (LineupDto lineup in headend.Lineups ?? [])
 566                {
 22567                    lineups.Add(new NameIdPair
 22568                    {
 22569                        Name = string.IsNullOrWhiteSpace(lineup.Name) ? lineup.Lineup : lineup.Name,
 22570                        Id = string.IsNullOrWhiteSpace(lineup.Lineup) ? lineup.Uri?.Split('/')[^1] : lineup.Lineup
 22571                    });
 572                }
 573            }
 574
 2575            if (lineups.Count == 0)
 576            {
 0577                _logger.LogWarning(
 0578                    "Schedules Direct has no lineups for country {Country} and postal code {PostalCode}",
 0579                    country,
 0580                    location);
 581            }
 582
 2583            return lineups;
 2584        }
 585
 586        private void ResetErrorState(ListingsProviderInfo info)
 587        {
 2588            _accountError = false;
 2589            Interlocked.Exchange(ref _lastErrorResponseTicks, 0);
 590
 591            // Only the account being saved is retried, the tokens of the other accounts stay valid.
 2592            if (!string.IsNullOrWhiteSpace(info.Username))
 593            {
 2594                _tokens.TryRemove(info.Username, out _);
 595            }
 2596        }
 597
 598        private async Task<string> GetToken(ListingsProviderInfo info, CancellationToken cancellationToken)
 599        {
 6600            var username = info.Username;
 601
 602            // Reset the token if there's no username
 6603            if (string.IsNullOrWhiteSpace(username))
 604            {
 0605                return null;
 606            }
 607
 6608            var password = info.Password;
 6609            if (string.IsNullOrEmpty(password))
 610            {
 0611                return null;
 612            }
 613
 614            // Account error — SD stays disabled until the provider is saved again or the server restarts.
 6615            if (_accountError)
 616            {
 0617                _logger.LogWarning("Skipping Schedules Direct request because of an earlier account error. Save the list
 618
 0619                return null;
 620            }
 621
 622            // Avoid hammering SD after transient login failures (e.g. max attempts / temporary lockout)
 6623            if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalM
 624            {
 0625                _logger.LogWarning("Skipping Schedules Direct request because of a recent login failure. Retrying no ear
 626
 0627                return null;
 628            }
 629
 6630            if (!_tokens.TryGetValue(username, out NameValuePair savedToken))
 631            {
 5632                savedToken = new NameValuePair();
 5633                _tokens.TryAdd(username, savedToken);
 634            }
 635
 6636            if (!string.IsNullOrEmpty(savedToken.Name)
 6637                && long.TryParse(savedToken.Value, CultureInfo.InvariantCulture, out long ticks))
 638            {
 639                // If it's under 24 hours old we can still use it
 1640                if (DateTime.UtcNow.Ticks - ticks < TimeSpan.FromHours(20).Ticks)
 641                {
 1642                    return savedToken.Name;
 643                }
 644            }
 645
 5646            using (await _tokenLock.LockAsync(cancellationToken).ConfigureAwait(false))
 647            {
 648                try
 649                {
 5650                    var result = await GetTokenInternal(username, password, cancellationToken).ConfigureAwait(false);
 2651                    savedToken.Name = result;
 2652                    savedToken.Value = DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture);
 2653                    return result;
 654                }
 3655                catch (HttpRequestException ex)
 656                {
 657                    // For 4xx errors not already handled by Request<T>'s SD code logic
 658                    // (e.g. unparseable response from the /token endpoint), apply a
 659                    // temporary backoff to avoid hammering SD.
 3660                    if (!_accountError
 3661                        && ex.StatusCode.HasValue
 3662                        && (int)ex.StatusCode.Value >= 400
 3663                        && (int)ex.StatusCode.Value < 500)
 664                    {
 0665                        _tokens.Clear();
 0666                        Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
 667                    }
 668
 3669                    throw;
 670                }
 671            }
 3672        }
 673
 674        private async Task<T> Request<T>(
 675            HttpRequestMessage message,
 676            bool enableRetry,
 677            ListingsProviderInfo providerInfo,
 678            CancellationToken cancellationToken,
 679            HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
 680        {
 7681            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 7682                .SendAsync(message, completionOption, cancellationToken)
 7683                .ConfigureAwait(false);
 7684            if (response.IsSuccessStatusCode)
 685            {
 4686                return await response.Content.ReadFromJsonAsync<T>(_jsonOptions, cancellationToken).ConfigureAwait(false
 687            }
 688
 3689            var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
 690
 691            // Try to extract the Schedules Direct error code from the response body.
 3692            SdErrorCode? sdCode = null;
 693            try
 694            {
 3695                using var doc = JsonDocument.Parse(responseBody);
 3696                if (doc.RootElement.TryGetProperty("code", out var codeProp)
 3697                    && codeProp.TryGetInt32(out var parsedCode)
 3698                    && Enum.IsDefined((SdErrorCode)parsedCode))
 699                {
 3700                    sdCode = (SdErrorCode)parsedCode;
 701                }
 3702            }
 0703            catch (JsonException)
 704            {
 705                // Response body is not valid JSON; sdCode stays null.
 0706            }
 707
 3708            _logger.LogError(
 3709                "Request to {Url} failed with HTTP {StatusCode}, SD code {SdCode}: {Response}",
 3710                message.RequestUri,
 3711                (int)response.StatusCode,
 3712                sdCode?.ToString() ?? "N/A",
 3713                responseBody);
 714
 3715            if (sdCode is SdErrorCode.AccountExpired or SdErrorCode.InvalidHash or SdErrorCode.InvalidUser or SdErrorCod
 716            {
 717                // Permanent account errors — disable SD for this server lifetime.
 3718                _logger.LogError("Schedules Direct account error (code {SdCode}). Disabling SD until server restart.", s
 3719                _tokens.Clear();
 3720                _accountError = true;
 721            }
 0722            else if (sdCode is SdErrorCode.ServiceOffline or SdErrorCode.ServiceBusy or SdErrorCode.AccountTempLock)
 723            {
 724                // Transient login errors — back off for 30 minutes, then allow retry.
 0725                _logger.LogError("Schedules Direct transient error (code {SdCode}). Backing off for 30 minutes.", sdCode
 0726                _tokens.Clear();
 0727                Interlocked.Exchange(ref _lastErrorResponseTicks, DateTime.UtcNow.Ticks);
 728            }
 0729            else if (sdCode is SdErrorCode.MaxLoginAttempts or SdErrorCode.MaxIPAttempts)
 730            {
 731                // 24 hour bans - stop image and metadata requests until SD reset at 00:00 UTC.
 0732                _logger.LogError("Schedules Direct service limit error (code {SdCode}). Disabling until SD reset.", sdCo
 0733                SetImageLimitHit();
 0734                SetMetadataLimitHit();
 735            }
 0736            else if (sdCode is SdErrorCode.MaxImageDownloads or SdErrorCode.MaxImageDownloadsTrial)
 737            {
 738                // Max image downloads — stop image requests until SD resets at 00:00 UTC.
 0739                _logger.LogError("Schedules Direct image download limit hit (code {SdCode}). Disabling image acquisition
 0740                SetImageLimitHit();
 741            }
 0742            else if (sdCode is SdErrorCode.MaxScheduleRequests)
 743            {
 744                // Max schedule/metadata requests — stop metadata requests until SD resets at 00:00 UTC.
 0745                _logger.LogError("Schedules Direct metadata download limit hit (code {SdCode}). Disabling metadata acqui
 0746                SetMetadataLimitHit();
 747            }
 0748            else if (enableRetry
 0749                && (int)response.StatusCode < 500
 0750                && (sdCode == SdErrorCode.TokenExpired || (response.StatusCode == HttpStatusCode.Forbidden && sdCode is 
 751            {
 752                // Token expired — clear tokens and retry with a fresh token.
 753                // Also retry on 403 with no parseable SD code (legacy/unexpected auth failure).
 0754                _tokens.Clear();
 0755                using var retryMessage = new HttpRequestMessage(message.Method, message.RequestUri);
 0756                retryMessage.Content = message.Content;
 0757                retryMessage.Headers.TryAddWithoutValidation(
 0758                    "token",
 0759                    await GetToken(providerInfo, cancellationToken).ConfigureAwait(false));
 760
 0761                return await Request<T>(retryMessage, false, providerInfo, cancellationToken).ConfigureAwait(false);
 762            }
 763
 3764            throw new HttpRequestException(
 3765                string.Format(CultureInfo.InvariantCulture, "Request failed: {0}", response.ReasonPhrase),
 3766                null,
 3767                response.StatusCode);
 4768        }
 769
 770        private async Task<string> GetTokenInternal(
 771            string username,
 772            string password,
 773            CancellationToken cancellationToken)
 774        {
 5775            using var options = new HttpRequestMessage(HttpMethod.Post, ApiUrl + "/token");
 776#pragma warning disable CA5350 // SchedulesDirect is always SHA1.
 5777            var hashedPasswordBytes = SHA1.HashData(Encoding.ASCII.GetBytes(password));
 778#pragma warning restore CA5350
 5779            string hashedPassword = Convert.ToHexStringLower(hashedPasswordBytes);
 5780            options.Content = new StringContent("{\"username\":\"" + username + "\",\"password\":\"" + hashedPassword + 
 781
 5782            var root = await Request<TokenDto>(options, false, null, cancellationToken).ConfigureAwait(false);
 2783            if (string.Equals(root?.Message, "OK", StringComparison.Ordinal))
 784            {
 2785                _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token);
 2786                return root.Token;
 787            }
 788
 0789            throw new AuthenticationException("Could not authenticate with Schedules Direct Error: " + (root?.Message ??
 2790        }
 791
 792        private async Task AddLineupToAccount(ListingsProviderInfo info, CancellationToken cancellationToken)
 793        {
 0794            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 795
 0796            ArgumentException.ThrowIfNullOrEmpty(token);
 0797            ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 798
 0799            _logger.LogInformation("Adding new lineup {Id}", info.ListingsId);
 800
 0801            using var message = new HttpRequestMessage(HttpMethod.Put, ApiUrl + "/lineups/" + info.ListingsId);
 0802            message.Headers.TryAddWithoutValidation("token", token);
 803
 0804            using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 0805                .SendAsync(message, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
 0806                .ConfigureAwait(false);
 807
 0808            if (!response.IsSuccessStatusCode)
 809            {
 0810                _logger.LogError(
 0811                    "Error adding lineup to account: {Response}",
 0812                    await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false));
 813            }
 0814        }
 815
 816        private async Task<bool> HasLineup(ListingsProviderInfo info, CancellationToken cancellationToken)
 817        {
 0818            ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 819
 0820            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 821
 0822            ArgumentException.ThrowIfNullOrEmpty(token);
 823
 0824            _logger.LogInformation("Headends on account ");
 825
 0826            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups");
 0827            options.Headers.TryAddWithoutValidation("token", token);
 828
 829            try
 830            {
 0831                var root = await Request<LineupsDto>(options, false, null, cancellationToken).ConfigureAwait(false);
 0832                return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCas
 833            }
 834            catch (HttpRequestException ex)
 835            {
 836                // SchedulesDirect returns 400 if no lineups are configured.
 0837                if (ex.StatusCode is HttpStatusCode.BadRequest)
 838                {
 0839                    return false;
 840                }
 841
 0842                throw;
 843            }
 0844        }
 845
 846        /// <inheritdoc />
 847        public async Task<Stream> GetAvailableCountries(CancellationToken cancellationToken)
 848        {
 0849            if (_countriesCache is not null)
 850            {
 0851                return new MemoryStream(_countriesCache, writable: false);
 852            }
 853
 0854            var cachePath = Path.Combine(_appPaths.CachePath, "sd-countries.json");
 855
 0856            if (File.Exists(cachePath)
 0857                && DateTime.UtcNow - File.GetLastWriteTimeUtc(cachePath) < TimeSpan.FromDays(CountryCacheDays))
 858            {
 859                try
 860                {
 0861                    _countriesCache = await File.ReadAllBytesAsync(cachePath, cancellationToken).ConfigureAwait(false);
 0862                    return new MemoryStream(_countriesCache, writable: false);
 863                }
 0864                catch (IOException)
 865                {
 866                    // Corrupt or unreadable — delete and re-fetch.
 0867                    TryDeleteFile(cachePath);
 0868                }
 869            }
 870
 0871            var client = _httpClientFactory.CreateClient(NamedClient.Default);
 0872            using var response = await client.GetAsync(new Uri(ApiUrl + "/available/countries"), cancellationToken).Conf
 0873            response.EnsureSuccessStatusCode();
 874
 0875            var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
 0876            Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
 0877            await File.WriteAllBytesAsync(cachePath, bytes, cancellationToken).ConfigureAwait(false);
 878
 0879            _countriesCache = bytes;
 0880            return new MemoryStream(bytes, writable: false);
 0881        }
 882
 883        private static DateOnly? LoadDailyLimitDate(string path)
 884        {
 62885            if (!File.Exists(path))
 886            {
 62887                return null;
 888            }
 889
 890            try
 891            {
 0892                var text = File.ReadAllText(path).Trim();
 0893                if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var date))
 894                {
 0895                    var dateOnly = DateOnly.FromDateTime(date);
 0896                    if (dateOnly < DateOnly.FromDateTime(DateTime.UtcNow))
 897                    {
 898                        // Expired — clean up.
 0899                        File.Delete(path);
 0900                        return null;
 901                    }
 902
 0903                    return dateOnly;
 904                }
 0905            }
 0906            catch (IOException)
 907            {
 908                // Corrupt or unreadable — delete and reset.
 0909                TryDeleteFile(path);
 0910            }
 911
 0912            return null;
 0913        }
 914
 915        /// <inheritdoc />
 916        public bool IsServiceAvailable()
 917        {
 0918            if (_accountError)
 919            {
 0920                return false;
 921            }
 922
 0923            if ((DateTime.UtcNow - new DateTime(Interlocked.Read(ref _lastErrorResponseTicks), DateTimeKind.Utc)).TotalM
 924            {
 0925                return false;
 926            }
 927
 0928            return true;
 929        }
 930
 931        /// <inheritdoc />
 932        public bool IsImageDailyLimitActive()
 933        {
 0934            if (!_imageLimitHitDate.HasValue)
 935            {
 0936                return false;
 937            }
 938
 0939            if (_imageLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow))
 940            {
 0941                _imageLimitHitDate = null;
 0942                TryDeleteFile(ImageLimitFilePath);
 0943                return false;
 944            }
 945
 0946            return true;
 947        }
 948
 949        private bool IsMetadataLimitActive()
 950        {
 0951            if (!_metadataLimitHitDate.HasValue)
 952            {
 0953                return false;
 954            }
 955
 0956            if (_metadataLimitHitDate.Value < DateOnly.FromDateTime(DateTime.UtcNow))
 957            {
 0958                _metadataLimitHitDate = null;
 0959                TryDeleteFile(MetadataLimitFilePath);
 0960                return false;
 961            }
 962
 0963            return true;
 964        }
 965
 966        private void SetImageLimitHit()
 967        {
 0968            _imageLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow);
 0969            PersistDailyLimitFile(ImageLimitFilePath);
 0970        }
 971
 972        private void SetMetadataLimitHit()
 973        {
 0974            _metadataLimitHitDate = DateOnly.FromDateTime(DateTime.UtcNow);
 0975            PersistDailyLimitFile(MetadataLimitFilePath);
 0976        }
 977
 978        private void PersistDailyLimitFile(string filePath)
 979        {
 980            try
 981            {
 0982                Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
 0983                File.WriteAllText(filePath, DateTime.UtcNow.ToString("O", CultureInfo.InvariantCulture));
 0984            }
 0985            catch (IOException ex)
 986            {
 0987                _logger.LogWarning(ex, "Failed to persist SD daily limit to {Path}", filePath);
 0988            }
 0989        }
 990
 991        private static void TryDeleteFile(string path)
 992        {
 993            try
 994            {
 0995                File.Delete(path);
 0996            }
 0997            catch (IOException)
 998            {
 999                // Best effort.
 01000            }
 01001        }
 1002
 1003        public async Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
 1004        {
 21005            ResetErrorState(info);
 1006
 21007            if (validateLogin)
 1008            {
 21009                ArgumentException.ThrowIfNullOrEmpty(info.Username);
 21010                ArgumentException.ThrowIfNullOrEmpty(info.Password);
 1011
 21012                var token = await GetToken(info, CancellationToken.None).ConfigureAwait(false);
 11013                if (string.IsNullOrWhiteSpace(token))
 1014                {
 01015                    throw new AuthenticationException("Could not authenticate with Schedules Direct");
 1016                }
 1017            }
 1018
 11019            if (validateListings)
 1020            {
 01021                ArgumentException.ThrowIfNullOrEmpty(info.ListingsId);
 1022
 01023                var hasLineup = await HasLineup(info, CancellationToken.None).ConfigureAwait(false);
 1024
 01025                if (!hasLineup)
 1026                {
 01027                    await AddLineupToAccount(info, CancellationToken.None).ConfigureAwait(false);
 1028                }
 1029            }
 11030        }
 1031
 1032        public Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location)
 1033        {
 41034            return GetHeadends(info, country, location, CancellationToken.None);
 1035        }
 1036
 1037        public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
 1038        {
 01039            var listingsId = info.ListingsId;
 01040            if (string.IsNullOrEmpty(listingsId))
 1041            {
 01042                return [];
 1043            }
 1044
 01045            var token = await GetToken(info, cancellationToken).ConfigureAwait(false);
 1046
 01047            if (string.IsNullOrEmpty(token))
 1048            {
 01049                return [];
 1050            }
 1051
 01052            using var options = new HttpRequestMessage(HttpMethod.Get, ApiUrl + "/lineups/" + listingsId);
 01053            options.Headers.TryAddWithoutValidation("token", token);
 1054
 01055            var root = await Request<ChannelDto>(options, true, info, cancellationToken).ConfigureAwait(false);
 01056            if (root is null)
 1057            {
 01058                return new List<ChannelInfo>();
 1059            }
 1060
 01061            _logger.LogInformation("Found {ChannelCount} channels on the lineup on ScheduleDirect", root.Map.Count);
 01062            _logger.LogInformation("Mapping Stations to Channel");
 1063
 01064            var allStations = root.Stations;
 1065
 01066            var map = root.Map;
 01067            var list = new List<ChannelInfo>(map.Count);
 01068            foreach (var channel in map)
 1069            {
 01070                var channelNumber = GetChannelNumber(channel);
 1071
 01072                var stationIndex = allStations.FindIndex(item => string.Equals(item.StationId, channel.StationId, String
 01073                var station = stationIndex == -1
 01074                    ? new StationDto { StationId = channel.StationId }
 01075                    : allStations[stationIndex];
 1076
 01077                var channelInfo = new ChannelInfo
 01078                {
 01079                    Id = station.StationId,
 01080                    CallSign = station.Callsign,
 01081                    Number = channelNumber,
 01082                    Name = string.IsNullOrWhiteSpace(station.Name) ? channelNumber : station.Name
 01083                };
 1084
 01085                if (station.Logo is not null)
 1086                {
 01087                    channelInfo.ImageUrl = station.Logo.Url;
 1088                }
 1089
 01090                list.Add(channelInfo);
 1091            }
 1092
 01093            return list;
 01094        }
 1095
 1096        /// <inheritdoc />
 1097        public void Dispose()
 1098        {
 851099            Dispose(true);
 851100            GC.SuppressFinalize(this);
 851101        }
 1102
 1103        /// <summary>
 1104        /// Releases unmanaged and optionally managed resources.
 1105        /// </summary>
 1106        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release
 1107        protected virtual void Dispose(bool disposing)
 1108        {
 851109            if (_disposed)
 1110            {
 541111                return;
 1112            }
 1113
 311114            if (disposing)
 1115            {
 311116                _tokenLock?.Dispose();
 1117            }
 1118
 311119            _disposed = true;
 311120        }
 1121    }
 1122}