< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.Listings.XmlTvListingsProvider
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
Line coverage
79%
Covered lines: 149
Uncovered lines: 39
Coverable lines: 188
Total lines: 381
Line coverage: 79.2%
Branch coverage
61%
Covered branches: 65
Total branches: 106
Branch coverage: 61.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 5/11/2026 - 12:15:59 AM Line coverage: 62.8% (88/140) Branch coverage: 53.6% (44/82) Total lines: 2965/20/2026 - 12:15:44 AM Line coverage: 62.8% (88/140) Branch coverage: 48.7% (40/82) Total lines: 2966/15/2026 - 12:16:09 AM Line coverage: 63.8% (92/144) Branch coverage: 46.8% (44/94) Total lines: 3017/22/2026 - 12:16:22 AM Line coverage: 64.5% (100/155) Branch coverage: 54.1% (52/96) Total lines: 32310/29/2026 - 12:17:11 AM Line coverage: 79.2% (149/188) Branch coverage: 61.3% (65/106) Total lines: 381 5/11/2026 - 12:15:59 AM Line coverage: 62.8% (88/140) Branch coverage: 53.6% (44/82) Total lines: 2965/20/2026 - 12:15:44 AM Line coverage: 62.8% (88/140) Branch coverage: 48.7% (40/82) Total lines: 2966/15/2026 - 12:16:09 AM Line coverage: 63.8% (92/144) Branch coverage: 46.8% (44/94) Total lines: 3017/22/2026 - 12:16:22 AM Line coverage: 64.5% (100/155) Branch coverage: 54.1% (52/96) Total lines: 32310/29/2026 - 12:17:11 AM Line coverage: 79.2% (149/188) Branch coverage: 61.3% (65/106) Total lines: 381

Coverage delta

Coverage delta 15 -15

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
get_Name()100%210%
get_Type()100%210%
GetLanguage(...)50%2266.66%
GetXml()70.83%242490.56%
TryDeleteTempFile(...)100%1150%
UnzipIfNeededAndCopy()50%10866.66%
GetProgramsAsync()50%2288.88%
GetProgramInfoWithEtag(...)50%3236.36%
GetProgramInfo(...)61.29%686288.67%
Validate(...)50%6680%
GetLineups()100%210%
GetChannels()100%11100%

File(s)

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

#LineLine coverage
 1#pragma warning disable CS1591
 2
 3using System;
 4using System.Collections.Concurrent;
 5using System.Collections.Generic;
 6using System.Globalization;
 7using System.IO;
 8using System.IO.Compression;
 9using System.Linq;
 10using System.Net.Http;
 11using System.Threading;
 12using System.Threading.Tasks;
 13using Jellyfin.Extensions;
 14using Jellyfin.XmlTv;
 15using Jellyfin.XmlTv.Entities;
 16using Jellyfin.XmlTv.Enums;
 17using MediaBrowser.Common.Extensions;
 18using MediaBrowser.Common.Net;
 19using MediaBrowser.Controller.Configuration;
 20using MediaBrowser.Controller.LiveTv;
 21using MediaBrowser.Model.Dto;
 22using MediaBrowser.Model.IO;
 23using MediaBrowser.Model.LiveTv;
 24using Microsoft.Extensions.Logging;
 25
 26namespace Jellyfin.LiveTv.Listings
 27{
 28    public class XmlTvListingsProvider : IListingsProvider
 29    {
 130        private static readonly TimeSpan _maxCacheAge = TimeSpan.FromHours(1);
 131        private static readonly TimeSpan _downloadTimeout = TimeSpan.FromMinutes(15);
 32
 33        private readonly IServerConfigurationManager _config;
 34        private readonly IHttpClientFactory _httpClientFactory;
 35        private readonly ILogger<XmlTvListingsProvider> _logger;
 36
 4637        private readonly ConcurrentDictionary<string, DateTime> _lastDownloadFailures = new(StringComparer.Ordinal);
 38
 39        public XmlTvListingsProvider(
 40            IServerConfigurationManager config,
 41            IHttpClientFactory httpClientFactory,
 42            ILogger<XmlTvListingsProvider> logger)
 43        {
 4644            _config = config;
 4645            _httpClientFactory = httpClientFactory;
 4646            _logger = logger;
 4647        }
 48
 049        public string Name => "XmlTV";
 50
 051        public string Type => "xmltv";
 52
 53        private string GetLanguage(ListingsProviderInfo info)
 54        {
 2755            if (!string.IsNullOrWhiteSpace(info.PreferredLanguage))
 56            {
 057                return info.PreferredLanguage;
 58            }
 59
 2760            return _config.Configuration.PreferredMetadataLanguage;
 61        }
 62
 63        private async Task<string> GetXml(ListingsProviderInfo info, CancellationToken cancellationToken)
 64        {
 2965            _logger.LogInformation("xmltv path: {Path}", info.Path);
 66
 2967            string cacheFilename = info.Id + ".xml";
 2968            string cacheDir = Path.Join(_config.ApplicationPaths.CachePath, "xmltv");
 2969            string cacheFile = Path.Join(cacheDir, cacheFilename);
 70
 2971            if (File.Exists(cacheFile) && File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge))
 72            {
 173                return cacheFile;
 74            }
 75
 2876            var isRemote = info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase);
 77
 2878            if (isRemote
 2879                && _lastDownloadFailures.TryGetValue(info.Path, out var lastFailure)
 2880                && DateTime.UtcNow - lastFailure < _maxCacheAge)
 81            {
 082                if (File.Exists(cacheFile))
 83                {
 084                    return cacheFile;
 85                }
 86
 087                throw new InvalidOperationException("Skipping the XMLTV download after a recent failure: " + info.Path);
 88            }
 89
 2890            Directory.CreateDirectory(cacheDir);
 91
 2892            var tempFile = cacheFile + ".tmp";
 93
 94            try
 95            {
 2896                using var timeout = new CancellationTokenSource(_downloadTimeout);
 2897                using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout
 2898                var downloadCancellationToken = linkedTokenSource.Token;
 99
 28100                if (isRemote)
 101                {
 9102                    _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path);
 103
 9104                    var httpClient = _httpClientFactory.CreateClient(NamedClient.Default);
 9105                    httpClient.Timeout = _downloadTimeout;
 106
 9107                    using var response = await httpClient
 9108                        .GetAsync(info.Path, HttpCompletionOption.ResponseHeadersRead, downloadCancellationToken)
 9109                        .ConfigureAwait(false);
 8110                    response.EnsureSuccessStatusCode();
 6111                    var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path;
 6112                    var stream = await response.Content.ReadAsStreamAsync(downloadCancellationToken).ConfigureAwait(fals
 6113                    await using (stream.ConfigureAwait(false))
 114                    {
 6115                        await UnzipIfNeededAndCopy(redirectedUrl, stream, tempFile, downloadCancellationToken).Configure
 116                    }
 6117                }
 118                else
 119                {
 19120                    var stream = AsyncFile.OpenRead(info.Path);
 19121                    await using (stream.ConfigureAwait(false))
 122                    {
 19123                        await UnzipIfNeededAndCopy(info.Path, stream, tempFile, downloadCancellationToken).ConfigureAwai
 124                    }
 125                }
 126
 25127                File.Move(tempFile, cacheFile, true);
 25128                _lastDownloadFailures.TryRemove(info.Path, out _);
 129
 25130                return cacheFile;
 131            }
 1132            catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
 133            {
 0134                TryDeleteTempFile(tempFile);
 135
 0136                throw;
 137            }
 3138            catch (Exception ex)
 139            {
 3140                TryDeleteTempFile(tempFile);
 3141                _lastDownloadFailures[info.Path] = DateTime.UtcNow;
 142
 3143                _logger.LogError(ex, "Error downloading or processing XMLTV file from {Path}", info.Path);
 144
 3145                if (File.Exists(cacheFile))
 146                {
 1147                    _logger.LogWarning("Falling back to the previously downloaded XMLTV file for {Path}", info.Path);
 148
 1149                    return cacheFile;
 150                }
 151
 2152                if (ex is OperationCanceledException)
 153                {
 1154                    throw new TimeoutException(
 1155                        string.Format(CultureInfo.InvariantCulture, "Timed out downloading the XMLTV file from {0}", inf
 1156                        ex);
 157                }
 158
 1159                throw;
 160            }
 27161        }
 162
 163        private void TryDeleteTempFile(string tempFile)
 164        {
 165            try
 166            {
 3167                File.Delete(tempFile);
 3168            }
 0169            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
 170            {
 0171                _logger.LogWarning(ex, "Error deleting temporary XMLTV file {File}", tempFile);
 0172            }
 3173        }
 174
 175        private async Task UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToken cancel
 176        {
 25177            var fileStream = new FileStream(
 25178                file,
 25179                FileMode.Create,
 25180                FileAccess.Write,
 25181                FileShare.None,
 25182                IODefaults.FileStreamBufferSize,
 25183                FileOptions.Asynchronous);
 184
 25185            await using (fileStream.ConfigureAwait(false))
 186            {
 25187                if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCa
 25188                    Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gzip", StringComparison.OrdinalIgnore
 189                {
 190                    try
 191                    {
 0192                        using var reader = new GZipStream(stream, CompressionMode.Decompress);
 0193                        await reader.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
 0194                    }
 0195                    catch (Exception ex)
 196                    {
 0197                        _logger.LogError(ex, "Error extracting from gz file {File}", originalUrl);
 0198                    }
 199                }
 200                else
 201                {
 25202                    await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
 203                }
 204            }
 205
 25206            var fileInfo = new FileInfo(file);
 25207            if (!fileInfo.Exists || fileInfo.Length == 0)
 208            {
 0209                throw new InvalidOperationException("Downloaded XMLTV file is empty: " + originalUrl);
 210            }
 25211        }
 212
 213        public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTi
 214        {
 27215            if (string.IsNullOrWhiteSpace(channelId))
 216            {
 0217                throw new ArgumentNullException(nameof(channelId));
 218            }
 219
 27220            _logger.LogDebug("Getting xmltv programs for channel {Id}", channelId);
 221
 27222            string path = await GetXml(info, cancellationToken).ConfigureAwait(false);
 25223            _logger.LogDebug("Opening XmlTvReader for {Path}", path);
 25224            var reader = new XmlTvReader(path, GetLanguage(info));
 225
 25226            return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken)
 25227                        .Select(p => GetProgramInfoWithEtag(p, info));
 25228        }
 229
 230        private ProgramInfo GetProgramInfoWithEtag(XmlTvProgram program, ListingsProviderInfo info)
 231        {
 25232            var programInfo = GetProgramInfo(program, info);
 233
 25234            if (XmlTvProgramEtag.TryCreate(programInfo, out var etag, out var reason))
 235            {
 25236                programInfo.Etag = etag;
 237            }
 238            else
 239            {
 0240                _logger.LogDebug(
 0241                    "Unable to create XMLTV program ETag for program {ProgramId} on channel {ChannelId} from {StartDate}
 0242                    programInfo.Id,
 0243                    programInfo.ChannelId,
 0244                    programInfo.StartDate,
 0245                    programInfo.EndDate,
 0246                    reason);
 247            }
 248
 25249            return programInfo;
 250        }
 251
 252        private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info)
 253        {
 25254            string? episodeTitle = program.Episode?.Title;
 25255            var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList();
 25256            var imageUrl = program.Icons.FirstOrDefault()?.Source;
 25257            var episodeImageUrl = program.Images?.FirstOrDefault(m => m.Type == ImageType.Still)?.Path;
 25258            var backgroundImageUrl = program.Images?.FirstOrDefault(m => m.Type == ImageType.Backdrop)?.Path;
 25259            var rating = program.Ratings.FirstOrDefault()?.Value;
 25260            var starRating = program.StarRatings?.FirstOrDefault()?.StarRating;
 261
 25262            var programInfo = new ProgramInfo
 25263            {
 25264                ChannelId = program.ChannelId,
 25265                EndDate = program.EndDate.UtcDateTime,
 25266                EpisodeNumber = program.Episode?.Episode,
 25267                EpisodeTitle = episodeTitle,
 25268                Genres = programCategories,
 25269                StartDate = program.StartDate.UtcDateTime,
 25270                Name = program.Title,
 25271                Overview = program.Description,
 25272                ProductionYear = program.CopyrightDate?.Year,
 25273                SeasonNumber = program.Episode?.Series,
 25274                IsSeries = program.Episode?.Episode is not null,
 25275                IsRepeat = program.IsPreviouslyShown && !program.IsNew,
 25276                IsPremiere = program.Premiere is not null,
 25277                IsLive = program.IsLive,
 25278                IsKids = programCategories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase))
 25279                IsMovie = programCategories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase
 25280                IsNews = programCategories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase))
 25281                IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCa
 25282                ImageUrl = string.IsNullOrEmpty(imageUrl) ? null : imageUrl,
 25283                HasImage = !string.IsNullOrEmpty(imageUrl),
 25284                BackdropImageUrl = string.IsNullOrEmpty(backgroundImageUrl) ? null : backgroundImageUrl,
 25285                ThumbImageUrl = string.IsNullOrEmpty(episodeImageUrl) ? null : episodeImageUrl,
 25286                OfficialRating = string.IsNullOrEmpty(rating) ? null : rating,
 25287                CommunityRating = starRating is null ? null : (float)starRating.Value,
 25288                SeriesId = program.Episode?.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.I
 25289            };
 290
 25291            if (string.IsNullOrWhiteSpace(program.ProgramId))
 292            {
 9293                string uniqueString = (program.Title ?? string.Empty) + (episodeTitle ?? string.Empty);
 294
 9295                if (programInfo.SeasonNumber.HasValue)
 296                {
 0297                    uniqueString = "-" + programInfo.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture);
 298                }
 299
 9300                if (programInfo.EpisodeNumber.HasValue)
 301                {
 0302                    uniqueString = "-" + programInfo.EpisodeNumber.Value.ToString(CultureInfo.InvariantCulture);
 303                }
 304
 9305                programInfo.ShowId = uniqueString.GetMD5().ToString("N", CultureInfo.InvariantCulture);
 306
 307                // If we don't have valid episode info, assume it's a unique program, otherwise recordings might be skip
 9308                if (programInfo.IsSeries
 9309                    && !programInfo.IsRepeat
 9310                    && (programInfo.EpisodeNumber ?? 0) == 0)
 311                {
 0312                    programInfo.ShowId += programInfo.StartDate.Ticks.ToString(CultureInfo.InvariantCulture);
 313                }
 314            }
 315            else
 316            {
 16317                programInfo.ShowId = program.ProgramId;
 318            }
 319
 320            // Construct an id from the channel and start date
 25321            programInfo.Id = string.Format(CultureInfo.InvariantCulture, "{0}_{1:O}", program.ChannelId, program.StartDa
 322
 25323            if (programInfo.IsMovie)
 324            {
 0325                programInfo.IsSeries = false;
 0326                programInfo.EpisodeNumber = null;
 0327                programInfo.EpisodeTitle = null;
 328            }
 329
 25330            return programInfo;
 331        }
 332
 333        public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
 334        {
 335            // Saving the provider is an explicit retry, so the download backoff has to be dropped
 336            // together with the cached file the listings manager deletes.
 1337            if (!string.IsNullOrEmpty(info.Path))
 338            {
 1339                _lastDownloadFailures.TryRemove(info.Path, out _);
 340            }
 341
 342            // Assume all urls are valid. check files for existence
 1343            if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !File.Exists(info.Path))
 344            {
 0345                throw new FileNotFoundException("Could not find the XmlTv file specified:", info.Path);
 346            }
 347
 1348            return Task.CompletedTask;
 349        }
 350
 351        public async Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location)
 352        {
 353            // In theory this should never be called because there is always only one lineup
 0354            string path = await GetXml(info, CancellationToken.None).ConfigureAwait(false);
 0355            _logger.LogDebug("Opening XmlTvReader for {Path}", path);
 0356            var reader = new XmlTvReader(path, GetLanguage(info));
 0357            IEnumerable<XmlTvChannel> results = reader.GetChannels();
 358
 359            // Should this method be async?
 0360            return results.Select(c => new NameIdPair() { Id = c.Id, Name = c.DisplayName }).ToList();
 0361        }
 362
 363        public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
 364        {
 365            // In theory this should never be called because there is always only one lineup
 2366            string path = await GetXml(info, cancellationToken).ConfigureAwait(false);
 2367            _logger.LogDebug("Opening XmlTvReader for {Path}", path);
 2368            var reader = new XmlTvReader(path, GetLanguage(info));
 2369            var results = reader.GetChannels();
 370
 371            // Should this method be async?
 2372            return results.Select(c => new ChannelInfo
 2373            {
 2374                Id = c.Id,
 2375                Name = c.DisplayName,
 2376                ImageUrl = string.IsNullOrEmpty(c.Icons.FirstOrDefault()?.Source) ? null : c.Icons.FirstOrDefault()!.Sou
 2377                Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number
 2378            }).ToList();
 2379        }
 380    }
 381}