< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.Listings.XmlTvListingsProvider
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
Line coverage
63%
Covered lines: 92
Uncovered lines: 52
Coverable lines: 144
Total lines: 301
Line coverage: 63.8%
Branch coverage
46%
Covered branches: 44
Total branches: 94
Branch coverage: 46.8%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 3/17/2026 - 12:14:16 AM Line coverage: 75.8% (44/58) Branch coverage: 54.7% (23/42) Total lines: 2694/19/2026 - 12:14:27 AM Line coverage: 66.6% (86/129) Branch coverage: 56.7% (42/74) Total lines: 2715/6/2026 - 12:15:23 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: 301 3/17/2026 - 12:14:16 AM Line coverage: 75.8% (44/58) Branch coverage: 54.7% (23/42) Total lines: 2694/19/2026 - 12:14:27 AM Line coverage: 66.6% (86/129) Branch coverage: 56.7% (42/74) Total lines: 2715/6/2026 - 12:15:23 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: 301

Coverage delta

Coverage delta 10 -10

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%11100%
get_Name()100%210%
get_Type()100%210%
GetLanguage(...)50%2266.66%
GetXml()42.85%241462.96%
UnzipIfNeededAndCopy()40%151062.5%
GetProgramsAsync()50%2288.88%
GetProgramInfo(...)51.61%716286.79%
Validate(...)0%2040%
GetLineups()100%210%
GetChannels()100%210%

File(s)

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

#LineLine coverage
 1#pragma warning disable CS1591
 2
 3using System;
 4using System.Collections.Generic;
 5using System.Globalization;
 6using System.IO;
 7using System.IO.Compression;
 8using System.Linq;
 9using System.Net.Http;
 10using System.Threading;
 11using System.Threading.Tasks;
 12using Jellyfin.Extensions;
 13using Jellyfin.XmlTv;
 14using Jellyfin.XmlTv.Entities;
 15using Jellyfin.XmlTv.Enums;
 16using MediaBrowser.Common.Extensions;
 17using MediaBrowser.Common.Net;
 18using MediaBrowser.Controller.Configuration;
 19using MediaBrowser.Controller.LiveTv;
 20using MediaBrowser.Model.Dto;
 21using MediaBrowser.Model.IO;
 22using MediaBrowser.Model.LiveTv;
 23using Microsoft.Extensions.Logging;
 24
 25namespace Jellyfin.LiveTv.Listings
 26{
 27    public class XmlTvListingsProvider : IListingsProvider
 28    {
 029        private static readonly TimeSpan _maxCacheAge = TimeSpan.FromHours(1);
 30
 31        private readonly IServerConfigurationManager _config;
 32        private readonly IHttpClientFactory _httpClientFactory;
 33        private readonly ILogger<XmlTvListingsProvider> _logger;
 34
 35        public XmlTvListingsProvider(
 36            IServerConfigurationManager config,
 37            IHttpClientFactory httpClientFactory,
 38            ILogger<XmlTvListingsProvider> logger)
 39        {
 2540            _config = config;
 2541            _httpClientFactory = httpClientFactory;
 2542            _logger = logger;
 2543        }
 44
 045        public string Name => "XmlTV";
 46
 047        public string Type => "xmltv";
 48
 49        private string GetLanguage(ListingsProviderInfo info)
 50        {
 451            if (!string.IsNullOrWhiteSpace(info.PreferredLanguage))
 52            {
 053                return info.PreferredLanguage;
 54            }
 55
 456            return _config.Configuration.PreferredMetadataLanguage;
 57        }
 58
 59        private async Task<string> GetXml(ListingsProviderInfo info, CancellationToken cancellationToken)
 60        {
 461            _logger.LogInformation("xmltv path: {Path}", info.Path);
 62
 463            string cacheFilename = info.Id + ".xml";
 464            string cacheDir = Path.Join(_config.ApplicationPaths.CachePath, "xmltv");
 465            string cacheFile = Path.Join(cacheDir, cacheFilename);
 66
 467            if (File.Exists(cacheFile))
 68            {
 069                if (File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge))
 70                {
 071                    return cacheFile;
 72                }
 73
 074                File.Delete(cacheFile);
 75            }
 76            else
 77            {
 478                Directory.CreateDirectory(cacheDir);
 79            }
 80
 81            try
 82            {
 483                if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
 84                {
 285                    _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path);
 86
 287                    using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, 
 288                    var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path;
 289                    var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 290                    await using (stream.ConfigureAwait(false))
 91                    {
 292                        return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).Configure
 93                    }
 094                }
 95                else
 96                {
 297                    var stream = AsyncFile.OpenRead(info.Path);
 298                    await using (stream.ConfigureAwait(false))
 99                    {
 2100                        return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwai
 101                    }
 102                }
 0103            }
 0104            catch (Exception ex)
 105            {
 0106                _logger.LogError(ex, "Error downloading or processing XMLTV file from {Path}", info.Path);
 107
 0108                if (File.Exists(cacheFile))
 109                {
 0110                    File.Delete(cacheFile);
 111                }
 112
 0113                throw;
 114            }
 4115        }
 116
 117        private async Task<string> UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToke
 118        {
 4119            var fileStream = new FileStream(
 4120                file,
 4121                FileMode.CreateNew,
 4122                FileAccess.Write,
 4123                FileShare.None,
 4124                IODefaults.FileStreamBufferSize,
 4125                FileOptions.Asynchronous);
 126
 4127            await using (fileStream.ConfigureAwait(false))
 128            {
 4129                if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCa
 4130                    Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gzip", StringComparison.OrdinalIgnore
 131                {
 132                    try
 133                    {
 0134                        using var reader = new GZipStream(stream, CompressionMode.Decompress);
 0135                        await reader.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
 0136                    }
 0137                    catch (Exception ex)
 138                    {
 0139                        _logger.LogError(ex, "Error extracting from gz file {File}", originalUrl);
 0140                    }
 141                }
 142                else
 143                {
 4144                    await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
 145                }
 146            }
 147
 4148            var fileInfo = new FileInfo(file);
 4149            if (!fileInfo.Exists || fileInfo.Length == 0)
 150            {
 0151                if (fileInfo.Exists)
 152                {
 0153                    File.Delete(file);
 154                }
 155
 0156                throw new InvalidOperationException("Downloaded XMLTV file is empty: " + originalUrl);
 157            }
 158
 4159            return file;
 4160        }
 161
 162        public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTi
 163        {
 4164            if (string.IsNullOrWhiteSpace(channelId))
 165            {
 0166                throw new ArgumentNullException(nameof(channelId));
 167            }
 168
 4169            _logger.LogDebug("Getting xmltv programs for channel {Id}", channelId);
 170
 4171            string path = await GetXml(info, cancellationToken).ConfigureAwait(false);
 4172            _logger.LogDebug("Opening XmlTvReader for {Path}", path);
 4173            var reader = new XmlTvReader(path, GetLanguage(info));
 174
 4175            return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken)
 4176                        .Select(p => GetProgramInfo(p, info));
 4177        }
 178
 179        private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info)
 180        {
 4181            string? episodeTitle = program.Episode?.Title;
 4182            var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList();
 4183            var imageUrl = program.Icons.FirstOrDefault()?.Source;
 4184            var episodeImageUrl = program.Images?.FirstOrDefault(m => m.Type == ImageType.Still)?.Path;
 4185            var backgroundImageUrl = program.Images?.FirstOrDefault(m => m.Type == ImageType.Backdrop)?.Path;
 4186            var rating = program.Ratings.FirstOrDefault()?.Value;
 4187            var starRating = program.StarRatings?.FirstOrDefault()?.StarRating;
 188
 4189            var programInfo = new ProgramInfo
 4190            {
 4191                ChannelId = program.ChannelId,
 4192                EndDate = program.EndDate.UtcDateTime,
 4193                EpisodeNumber = program.Episode?.Episode,
 4194                EpisodeTitle = episodeTitle,
 4195                Genres = programCategories,
 4196                StartDate = program.StartDate.UtcDateTime,
 4197                Name = program.Title,
 4198                Overview = program.Description,
 4199                ProductionYear = program.CopyrightDate?.Year,
 4200                SeasonNumber = program.Episode?.Series,
 4201                IsSeries = program.Episode?.Episode is not null,
 4202                IsRepeat = program.IsPreviouslyShown && !program.IsNew,
 4203                IsPremiere = program.Premiere is not null,
 4204                IsLive = program.IsLive,
 4205                IsKids = programCategories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase))
 4206                IsMovie = programCategories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase
 4207                IsNews = programCategories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase))
 4208                IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCa
 4209                ImageUrl = string.IsNullOrEmpty(imageUrl) ? null : imageUrl,
 4210                HasImage = !string.IsNullOrEmpty(imageUrl),
 4211                BackdropImageUrl = string.IsNullOrEmpty(backgroundImageUrl) ? null : backgroundImageUrl,
 4212                ThumbImageUrl = string.IsNullOrEmpty(episodeImageUrl) ? null : episodeImageUrl,
 4213                OfficialRating = string.IsNullOrEmpty(rating) ? null : rating,
 4214                CommunityRating = starRating is null ? null : (float)starRating.Value,
 4215                SeriesId = program.Episode?.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.I
 4216            };
 217
 4218            if (string.IsNullOrWhiteSpace(program.ProgramId))
 219            {
 4220                string uniqueString = (program.Title ?? string.Empty) + (episodeTitle ?? string.Empty);
 221
 4222                if (programInfo.SeasonNumber.HasValue)
 223                {
 0224                    uniqueString = "-" + programInfo.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture);
 225                }
 226
 4227                if (programInfo.EpisodeNumber.HasValue)
 228                {
 0229                    uniqueString = "-" + programInfo.EpisodeNumber.Value.ToString(CultureInfo.InvariantCulture);
 230                }
 231
 4232                programInfo.ShowId = uniqueString.GetMD5().ToString("N", CultureInfo.InvariantCulture);
 233
 234                // If we don't have valid episode info, assume it's a unique program, otherwise recordings might be skip
 4235                if (programInfo.IsSeries
 4236                    && !programInfo.IsRepeat
 4237                    && (programInfo.EpisodeNumber ?? 0) == 0)
 238                {
 0239                    programInfo.ShowId += programInfo.StartDate.Ticks.ToString(CultureInfo.InvariantCulture);
 240                }
 241            }
 242            else
 243            {
 0244                programInfo.ShowId = program.ProgramId;
 245            }
 246
 247            // Construct an id from the channel and start date
 4248            programInfo.Id = string.Format(CultureInfo.InvariantCulture, "{0}_{1:O}", program.ChannelId, program.StartDa
 249
 4250            if (programInfo.IsMovie)
 251            {
 0252                programInfo.IsSeries = false;
 0253                programInfo.EpisodeNumber = null;
 0254                programInfo.EpisodeTitle = null;
 255            }
 256
 4257            return programInfo;
 258        }
 259
 260        public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
 261        {
 262            // Assume all urls are valid. check files for existence
 0263            if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !File.Exists(info.Path))
 264            {
 0265                throw new FileNotFoundException("Could not find the XmlTv file specified:", info.Path);
 266            }
 267
 0268            return Task.CompletedTask;
 269        }
 270
 271        public async Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location)
 272        {
 273            // In theory this should never be called because there is always only one lineup
 0274            string path = await GetXml(info, CancellationToken.None).ConfigureAwait(false);
 0275            _logger.LogDebug("Opening XmlTvReader for {Path}", path);
 0276            var reader = new XmlTvReader(path, GetLanguage(info));
 0277            IEnumerable<XmlTvChannel> results = reader.GetChannels();
 278
 279            // Should this method be async?
 0280            return results.Select(c => new NameIdPair() { Id = c.Id, Name = c.DisplayName }).ToList();
 0281        }
 282
 283        public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
 284        {
 285            // In theory this should never be called because there is always only one lineup
 0286            string path = await GetXml(info, cancellationToken).ConfigureAwait(false);
 0287            _logger.LogDebug("Opening XmlTvReader for {Path}", path);
 0288            var reader = new XmlTvReader(path, GetLanguage(info));
 0289            var results = reader.GetChannels();
 290
 291            // Should this method be async?
 0292            return results.Select(c => new ChannelInfo
 0293            {
 0294                Id = c.Id,
 0295                Name = c.DisplayName,
 0296                ImageUrl = string.IsNullOrEmpty(c.Icons.FirstOrDefault()?.Source) ? null : c.Icons.FirstOrDefault()!.Sou
 0297                Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number
 0298            }).ToList();
 0299        }
 300    }
 301}