< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.Listings.XmlTvListingsProvider
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/Listings/XmlTvListingsProvider.cs
Line coverage
66%
Covered lines: 86
Uncovered lines: 43
Coverable lines: 129
Total lines: 271
Line coverage: 66.6%
Branch coverage
56%
Covered branches: 42
Total branches: 74
Branch coverage: 56.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 1/6/2026 - 12:12:10 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: 271 1/6/2026 - 12:12:10 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: 271

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()50%131280.95%
UnzipIfNeededAndCopy()50%5468.42%
GetProgramsAsync()50%2288.88%
GetProgramInfo(...)64%575085.71%
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 MediaBrowser.Common.Extensions;
 16using MediaBrowser.Common.Net;
 17using MediaBrowser.Controller.Configuration;
 18using MediaBrowser.Controller.LiveTv;
 19using MediaBrowser.Model.Dto;
 20using MediaBrowser.Model.IO;
 21using MediaBrowser.Model.LiveTv;
 22using Microsoft.Extensions.Logging;
 23
 24namespace Jellyfin.LiveTv.Listings
 25{
 26    public class XmlTvListingsProvider : IListingsProvider
 27    {
 028        private static readonly TimeSpan _maxCacheAge = TimeSpan.FromHours(1);
 29
 30        private readonly IServerConfigurationManager _config;
 31        private readonly IHttpClientFactory _httpClientFactory;
 32        private readonly ILogger<XmlTvListingsProvider> _logger;
 33
 34        public XmlTvListingsProvider(
 35            IServerConfigurationManager config,
 36            IHttpClientFactory httpClientFactory,
 37            ILogger<XmlTvListingsProvider> logger)
 38        {
 2539            _config = config;
 2540            _httpClientFactory = httpClientFactory;
 2541            _logger = logger;
 2542        }
 43
 044        public string Name => "XmlTV";
 45
 046        public string Type => "xmltv";
 47
 48        private string GetLanguage(ListingsProviderInfo info)
 49        {
 450            if (!string.IsNullOrWhiteSpace(info.PreferredLanguage))
 51            {
 052                return info.PreferredLanguage;
 53            }
 54
 455            return _config.Configuration.PreferredMetadataLanguage;
 56        }
 57
 58        private async Task<string> GetXml(ListingsProviderInfo info, CancellationToken cancellationToken)
 59        {
 460            _logger.LogInformation("xmltv path: {Path}", info.Path);
 61
 462            string cacheFilename = info.Id + ".xml";
 463            string cacheDir = Path.Join(_config.ApplicationPaths.CachePath, "xmltv");
 464            string cacheFile = Path.Join(cacheDir, cacheFilename);
 65
 466            if (File.Exists(cacheFile))
 67            {
 068                if (File.GetLastWriteTimeUtc(cacheFile) >= DateTime.UtcNow.Subtract(_maxCacheAge))
 69                {
 070                    return cacheFile;
 71                }
 72
 073                File.Delete(cacheFile);
 74            }
 75            else
 76            {
 477                Directory.CreateDirectory(cacheDir);
 78            }
 79
 480            if (info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase))
 81            {
 282                _logger.LogInformation("Downloading xmltv listings from {Path}", info.Path);
 83
 284                using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(info.Path, canc
 285                var redirectedUrl = response.RequestMessage?.RequestUri?.ToString() ?? info.Path;
 286                var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 287                await using (stream.ConfigureAwait(false))
 88                {
 289                    return await UnzipIfNeededAndCopy(redirectedUrl, stream, cacheFile, cancellationToken).ConfigureAwai
 90                }
 091            }
 92            else
 93            {
 294                var stream = AsyncFile.OpenRead(info.Path);
 295                await using (stream.ConfigureAwait(false))
 96                {
 297                    return await UnzipIfNeededAndCopy(info.Path, stream, cacheFile, cancellationToken).ConfigureAwait(fa
 98                }
 99            }
 4100        }
 101
 102        private async Task<string> UnzipIfNeededAndCopy(string originalUrl, Stream stream, string file, CancellationToke
 103        {
 4104            var fileStream = new FileStream(
 4105                file,
 4106                FileMode.CreateNew,
 4107                FileAccess.Write,
 4108                FileShare.None,
 4109                IODefaults.FileStreamBufferSize,
 4110                FileOptions.Asynchronous);
 111
 4112            await using (fileStream.ConfigureAwait(false))
 113            {
 4114                if (Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gz", StringComparison.OrdinalIgnoreCa
 4115                    Path.GetExtension(originalUrl.AsSpan().LeftPart('?')).Equals(".gzip", StringComparison.OrdinalIgnore
 116                {
 117                    try
 118                    {
 0119                        using var reader = new GZipStream(stream, CompressionMode.Decompress);
 0120                        await reader.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
 0121                    }
 0122                    catch (Exception ex)
 123                    {
 0124                        _logger.LogError(ex, "Error extracting from gz file {File}", originalUrl);
 0125                    }
 126                }
 127                else
 128                {
 4129                    await stream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false);
 130                }
 131
 4132                return file;
 133            }
 4134        }
 135
 136        public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(ListingsProviderInfo info, string channelId, DateTi
 137        {
 4138            if (string.IsNullOrWhiteSpace(channelId))
 139            {
 0140                throw new ArgumentNullException(nameof(channelId));
 141            }
 142
 4143            _logger.LogDebug("Getting xmltv programs for channel {Id}", channelId);
 144
 4145            string path = await GetXml(info, cancellationToken).ConfigureAwait(false);
 4146            _logger.LogDebug("Opening XmlTvReader for {Path}", path);
 4147            var reader = new XmlTvReader(path, GetLanguage(info));
 148
 4149            return reader.GetProgrammes(channelId, startDateUtc, endDateUtc, cancellationToken)
 4150                        .Select(p => GetProgramInfo(p, info));
 4151        }
 152
 153        private static ProgramInfo GetProgramInfo(XmlTvProgram program, ListingsProviderInfo info)
 154        {
 4155            string? episodeTitle = program.Episode?.Title;
 4156            var programCategories = program.Categories.Where(c => !string.IsNullOrWhiteSpace(c)).ToList();
 4157            var imageUrl = program.Icons.FirstOrDefault()?.Source;
 4158            var rating = program.Ratings.FirstOrDefault()?.Value;
 4159            var starRating = program.StarRatings?.FirstOrDefault()?.StarRating;
 160
 4161            var programInfo = new ProgramInfo
 4162            {
 4163                ChannelId = program.ChannelId,
 4164                EndDate = program.EndDate.UtcDateTime,
 4165                EpisodeNumber = program.Episode?.Episode,
 4166                EpisodeTitle = episodeTitle,
 4167                Genres = programCategories,
 4168                StartDate = program.StartDate.UtcDateTime,
 4169                Name = program.Title,
 4170                Overview = program.Description,
 4171                ProductionYear = program.CopyrightDate?.Year,
 4172                SeasonNumber = program.Episode?.Series,
 4173                IsSeries = program.Episode?.Episode is not null,
 4174                IsRepeat = program.IsPreviouslyShown && !program.IsNew,
 4175                IsPremiere = program.Premiere is not null,
 4176                IsLive = program.IsLive,
 4177                IsKids = programCategories.Any(c => info.KidsCategories.Contains(c, StringComparison.OrdinalIgnoreCase))
 4178                IsMovie = programCategories.Any(c => info.MovieCategories.Contains(c, StringComparison.OrdinalIgnoreCase
 4179                IsNews = programCategories.Any(c => info.NewsCategories.Contains(c, StringComparison.OrdinalIgnoreCase))
 4180                IsSports = programCategories.Any(c => info.SportsCategories.Contains(c, StringComparison.OrdinalIgnoreCa
 4181                ImageUrl = string.IsNullOrEmpty(imageUrl) ? null : imageUrl,
 4182                HasImage = !string.IsNullOrEmpty(imageUrl),
 4183                OfficialRating = string.IsNullOrEmpty(rating) ? null : rating,
 4184                CommunityRating = starRating is null ? null : (float)starRating.Value,
 4185                SeriesId = program.Episode?.Episode is null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.I
 4186            };
 187
 4188            if (string.IsNullOrWhiteSpace(program.ProgramId))
 189            {
 4190                string uniqueString = (program.Title ?? string.Empty) + (episodeTitle ?? string.Empty);
 191
 4192                if (programInfo.SeasonNumber.HasValue)
 193                {
 0194                    uniqueString = "-" + programInfo.SeasonNumber.Value.ToString(CultureInfo.InvariantCulture);
 195                }
 196
 4197                if (programInfo.EpisodeNumber.HasValue)
 198                {
 0199                    uniqueString = "-" + programInfo.EpisodeNumber.Value.ToString(CultureInfo.InvariantCulture);
 200                }
 201
 4202                programInfo.ShowId = uniqueString.GetMD5().ToString("N", CultureInfo.InvariantCulture);
 203
 204                // If we don't have valid episode info, assume it's a unique program, otherwise recordings might be skip
 4205                if (programInfo.IsSeries
 4206                    && !programInfo.IsRepeat
 4207                    && (programInfo.EpisodeNumber ?? 0) == 0)
 208                {
 0209                    programInfo.ShowId += programInfo.StartDate.Ticks.ToString(CultureInfo.InvariantCulture);
 210                }
 211            }
 212            else
 213            {
 0214                programInfo.ShowId = program.ProgramId;
 215            }
 216
 217            // Construct an id from the channel and start date
 4218            programInfo.Id = string.Format(CultureInfo.InvariantCulture, "{0}_{1:O}", program.ChannelId, program.StartDa
 219
 4220            if (programInfo.IsMovie)
 221            {
 0222                programInfo.IsSeries = false;
 0223                programInfo.EpisodeNumber = null;
 0224                programInfo.EpisodeTitle = null;
 225            }
 226
 4227            return programInfo;
 228        }
 229
 230        public Task Validate(ListingsProviderInfo info, bool validateLogin, bool validateListings)
 231        {
 232            // Assume all urls are valid. check files for existence
 0233            if (!info.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase) && !File.Exists(info.Path))
 234            {
 0235                throw new FileNotFoundException("Could not find the XmlTv file specified:", info.Path);
 236            }
 237
 0238            return Task.CompletedTask;
 239        }
 240
 241        public async Task<List<NameIdPair>> GetLineups(ListingsProviderInfo info, string country, string location)
 242        {
 243            // In theory this should never be called because there is always only one lineup
 0244            string path = await GetXml(info, CancellationToken.None).ConfigureAwait(false);
 0245            _logger.LogDebug("Opening XmlTvReader for {Path}", path);
 0246            var reader = new XmlTvReader(path, GetLanguage(info));
 0247            IEnumerable<XmlTvChannel> results = reader.GetChannels();
 248
 249            // Should this method be async?
 0250            return results.Select(c => new NameIdPair() { Id = c.Id, Name = c.DisplayName }).ToList();
 0251        }
 252
 253        public async Task<List<ChannelInfo>> GetChannels(ListingsProviderInfo info, CancellationToken cancellationToken)
 254        {
 255            // In theory this should never be called because there is always only one lineup
 0256            string path = await GetXml(info, cancellationToken).ConfigureAwait(false);
 0257            _logger.LogDebug("Opening XmlTvReader for {Path}", path);
 0258            var reader = new XmlTvReader(path, GetLanguage(info));
 0259            var results = reader.GetChannels();
 260
 261            // Should this method be async?
 0262            return results.Select(c => new ChannelInfo
 0263            {
 0264                Id = c.Id,
 0265                Name = c.DisplayName,
 0266                ImageUrl = string.IsNullOrEmpty(c.Icons.FirstOrDefault()?.Source) ? null : c.Icons.FirstOrDefault()!.Sou
 0267                Number = string.IsNullOrWhiteSpace(c.Number) ? c.Id : c.Number
 0268            }).ToList();
 0269        }
 270    }
 271}