< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.Listings.XmlTvListingsProvider
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
Line coverage
64%
Covered lines: 100
Uncovered lines: 55
Coverable lines: 155
Total lines: 323
Line coverage: 64.5%
Branch coverage
54%
Covered branches: 52
Total branches: 96
Branch coverage: 54.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 4/25/2026 - 12:15:21 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: 3017/22/2026 - 12:16:22 AM Line coverage: 64.5% (100/155) Branch coverage: 54.1% (52/96) Total lines: 323 4/25/2026 - 12:15:21 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: 3017/22/2026 - 12:16:22 AM Line coverage: 64.5% (100/155) Branch coverage: 54.1% (52/96) Total lines: 323

Coverage delta

Coverage delta 8 -8

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()57.14%191470.37%
UnzipIfNeededAndCopy()40%151062.5%
GetProgramsAsync()50%2288.88%
GetProgramInfoWithEtag(...)50%3236.36%
GetProgramInfo(...)59.67%686288.67%
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    {
 129        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        {
 3440            _config = config;
 3441            _httpClientFactory = httpClientFactory;
 3442            _logger = logger;
 3443        }
 44
 045        public string Name => "XmlTV";
 46
 047        public string Type => "xmltv";
 48
 49        private string GetLanguage(ListingsProviderInfo info)
 50        {
 2051            if (!string.IsNullOrWhiteSpace(info.PreferredLanguage))
 52            {
 053                return info.PreferredLanguage;
 54            }
 55
 2056            return _config.Configuration.PreferredMetadataLanguage;
 57        }
 58
 59        private async Task<string> GetXml(ListingsProviderInfo info, CancellationToken cancellationToken)
 60        {
 2061            _logger.LogInformation("xmltv path: {Path}", info.Path);
 62
 2063            string cacheFilename = info.Id + ".xml";
 2064            string cacheDir = Path.Join(_config.ApplicationPaths.CachePath, "xmltv");
 2065            string cacheFile = Path.Join(cacheDir, cacheFilename);
 66
 2067            if (File.Exists(cacheFile))
 68            {
 169                if (File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge))
 70                {
 171                    return cacheFile;
 72                }
 73
 074                File.Delete(cacheFile);
 75            }
 76            else
 77            {
 1978                Directory.CreateDirectory(cacheDir);
 79            }
 80
 81            try
 82            {
 1983                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                {
 1797                    var stream = AsyncFile.OpenRead(info.Path);
 1798                    await using (stream.ConfigureAwait(false))
 99                    {
 17100                        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            }
 20115        }
 116
 117        private async Task<string> UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToke
 118        {
 19119            var fileStream = new FileStream(
 19120                file,
 19121                FileMode.CreateNew,
 19122                FileAccess.Write,
 19123                FileShare.None,
 19124                IODefaults.FileStreamBufferSize,
 19125                FileOptions.Asynchronous);
 126
 19127            await using (fileStream.ConfigureAwait(false))
 128            {
 19129                if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCa
 19130                    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                {
 19144                    await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
 145                }
 146            }
 147
 19148            var fileInfo = new FileInfo(file);
 19149            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
 19159            return file;
 19160        }
 161
 162        public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTi
 163        {
 20164            if (string.IsNullOrWhiteSpace(channelId))
 165            {
 0166                throw new ArgumentNullException(nameof(channelId));
 167            }
 168
 20169            _logger.LogDebug("Getting xmltv programs for channel {Id}", channelId);
 170
 20171            string path = await GetXml(info, cancellationToken).ConfigureAwait(false);
 20172            _logger.LogDebug("Opening XmlTvReader for {Path}", path);
 20173            var reader = new XmlTvReader(path, GetLanguage(info));
 174
 20175            return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken)
 20176                        .Select(p => GetProgramInfoWithEtag(p, info));
 20177        }
 178
 179        private ProgramInfo GetProgramInfoWithEtag(XmlTvProgram program, ListingsProviderInfo info)
 180        {
 20181            var programInfo = GetProgramInfo(program, info);
 182
 20183            if (XmlTvProgramEtag.TryCreate(programInfo, out var etag, out var reason))
 184            {
 20185                programInfo.Etag = etag;
 186            }
 187            else
 188            {
 0189                _logger.LogDebug(
 0190                    "Unable to create XMLTV program ETag for program {ProgramId} on channel {ChannelId} from {StartDate}
 0191                    programInfo.Id,
 0192                    programInfo.ChannelId,
 0193                    programInfo.StartDate,
 0194                    programInfo.EndDate,
 0195                    reason);
 196            }
 197
 20198            return programInfo;
 199        }
 200
 201        private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info)
 202        {
 20203            string? episodeTitle = program.Episode?.Title;
 20204            var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList();
 20205            var imageUrl = program.Icons.FirstOrDefault()?.Source;
 20206            var episodeImageUrl = program.Images?.FirstOrDefault(m => m.Type == ImageType.Still)?.Path;
 20207            var backgroundImageUrl = program.Images?.FirstOrDefault(m => m.Type == ImageType.Backdrop)?.Path;
 20208            var rating = program.Ratings.FirstOrDefault()?.Value;
 20209            var starRating = program.StarRatings?.FirstOrDefault()?.StarRating;
 210
 20211            var programInfo = new ProgramInfo
 20212            {
 20213                ChannelId = program.ChannelId,
 20214                EndDate = program.EndDate.UtcDateTime,
 20215                EpisodeNumber = program.Episode?.Episode,
 20216                EpisodeTitle = episodeTitle,
 20217                Genres = programCategories,
 20218                StartDate = program.StartDate.UtcDateTime,
 20219                Name = program.Title,
 20220                Overview = program.Description,
 20221                ProductionYear = program.CopyrightDate?.Year,
 20222                SeasonNumber = program.Episode?.Series,
 20223                IsSeries = program.Episode?.Episode is not null,
 20224                IsRepeat = program.IsPreviouslyShown && !program.IsNew,
 20225                IsPremiere = program.Premiere is not null,
 20226                IsLive = program.IsLive,
 20227                IsKids = programCategories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase))
 20228                IsMovie = programCategories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase
 20229                IsNews = programCategories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase))
 20230                IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCa
 20231                ImageUrl = string.IsNullOrEmpty(imageUrl) ? null : imageUrl,
 20232                HasImage = !string.IsNullOrEmpty(imageUrl),
 20233                BackdropImageUrl = string.IsNullOrEmpty(backgroundImageUrl) ? null : backgroundImageUrl,
 20234                ThumbImageUrl = string.IsNullOrEmpty(episodeImageUrl) ? null : episodeImageUrl,
 20235                OfficialRating = string.IsNullOrEmpty(rating) ? null : rating,
 20236                CommunityRating = starRating is null ? null : (float)starRating.Value,
 20237                SeriesId = program.Episode?.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.I
 20238            };
 239
 20240            if (string.IsNullOrWhiteSpace(program.ProgramId))
 241            {
 4242                string uniqueString = (program.Title ?? string.Empty) + (episodeTitle ?? string.Empty);
 243
 4244                if (programInfo.SeasonNumber.HasValue)
 245                {
 0246                    uniqueString = "-" + programInfo.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture);
 247                }
 248
 4249                if (programInfo.EpisodeNumber.HasValue)
 250                {
 0251                    uniqueString = "-" + programInfo.EpisodeNumber.Value.ToString(CultureInfo.InvariantCulture);
 252                }
 253
 4254                programInfo.ShowId = uniqueString.GetMD5().ToString("N", CultureInfo.InvariantCulture);
 255
 256                // If we don't have valid episode info, assume it's a unique program, otherwise recordings might be skip
 4257                if (programInfo.IsSeries
 4258                    && !programInfo.IsRepeat
 4259                    && (programInfo.EpisodeNumber ?? 0) == 0)
 260                {
 0261                    programInfo.ShowId += programInfo.StartDate.Ticks.ToString(CultureInfo.InvariantCulture);
 262                }
 263            }
 264            else
 265            {
 16266                programInfo.ShowId = program.ProgramId;
 267            }
 268
 269            // Construct an id from the channel and start date
 20270            programInfo.Id = string.Format(CultureInfo.InvariantCulture, "{0}_{1:O}", program.ChannelId, program.StartDa
 271
 20272            if (programInfo.IsMovie)
 273            {
 0274                programInfo.IsSeries = false;
 0275                programInfo.EpisodeNumber = null;
 0276                programInfo.EpisodeTitle = null;
 277            }
 278
 20279            return programInfo;
 280        }
 281
 282        public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
 283        {
 284            // Assume all urls are valid. check files for existence
 0285            if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !File.Exists(info.Path))
 286            {
 0287                throw new FileNotFoundException("Could not find the XmlTv file specified:", info.Path);
 288            }
 289
 0290            return Task.CompletedTask;
 291        }
 292
 293        public async Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location)
 294        {
 295            // In theory this should never be called because there is always only one lineup
 0296            string path = await GetXml(info, CancellationToken.None).ConfigureAwait(false);
 0297            _logger.LogDebug("Opening XmlTvReader for {Path}", path);
 0298            var reader = new XmlTvReader(path, GetLanguage(info));
 0299            IEnumerable<XmlTvChannel> results = reader.GetChannels();
 300
 301            // Should this method be async?
 0302            return results.Select(c => new NameIdPair() { Id = c.Id, Name = c.DisplayName }).ToList();
 0303        }
 304
 305        public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
 306        {
 307            // In theory this should never be called because there is always only one lineup
 0308            string path = await GetXml(info, cancellationToken).ConfigureAwait(false);
 0309            _logger.LogDebug("Opening XmlTvReader for {Path}", path);
 0310            var reader = new XmlTvReader(path, GetLanguage(info));
 0311            var results = reader.GetChannels();
 312
 313            // Should this method be async?
 0314            return results.Select(c => new ChannelInfo
 0315            {
 0316                Id = c.Id,
 0317                Name = c.DisplayName,
 0318                ImageUrl = string.IsNullOrEmpty(c.Icons.FirstOrDefault()?.Source) ? null : c.Icons.FirstOrDefault()!.Sou
 0319                Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number
 0320            }).ToList();
 0321        }
 322    }
 323}