< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.Listings.ListingsManager
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/Listings/ListingsManager.cs
Line coverage
7%
Covered lines: 17
Uncovered lines: 209
Coverable lines: 226
Total lines: 506
Line coverage: 7.5%
Branch coverage
2%
Covered branches: 2
Total branches: 94
Branch coverage: 2.1%
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: 7.5% (17/225) Branch coverage: 3.2% (3/92) Total lines: 5035/20/2026 - 12:15:44 AM Line coverage: 7.5% (17/225) Branch coverage: 2.1% (2/92) Total lines: 50310/29/2026 - 12:17:11 AM Line coverage: 7.5% (17/226) Branch coverage: 2.1% (2/94) Total lines: 506 5/16/2026 - 12:15:55 AM Line coverage: 7.5% (17/225) Branch coverage: 3.2% (3/92) Total lines: 5035/20/2026 - 12:15:44 AM Line coverage: 7.5% (17/225) Branch coverage: 2.1% (2/92) Total lines: 50310/29/2026 - 12:17:11 AM Line coverage: 7.5% (17/226) Branch coverage: 2.1% (2/94) Total lines: 506

Coverage delta

Coverage delta 2 -2

Metrics

File(s)

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

#LineLine coverage
 1using System;
 2using System.Collections.Concurrent;
 3using System.Collections.Generic;
 4using System.Globalization;
 5using System.IO;
 6using System.Linq;
 7using System.Threading;
 8using System.Threading.Tasks;
 9using Jellyfin.LiveTv.Configuration;
 10using Jellyfin.LiveTv.Guide;
 11using MediaBrowser.Common.Configuration;
 12using MediaBrowser.Common.Extensions;
 13using MediaBrowser.Controller.LiveTv;
 14using MediaBrowser.Model.Dto;
 15using MediaBrowser.Model.LiveTv;
 16using MediaBrowser.Model.Tasks;
 17using Microsoft.Extensions.Logging;
 18
 19namespace Jellyfin.LiveTv.Listings;
 20
 21/// <inheritdoc />
 22public class ListingsManager : IListingsManager
 23{
 24    private readonly ILogger<ListingsManager> _logger;
 25    private readonly IConfigurationManager _config;
 26    private readonly ITaskManager _taskManager;
 27    private readonly ITunerHostManager _tunerHostManager;
 28    private readonly IListingsProvider[] _listingsProviders;
 29
 2830    private readonly ConcurrentDictionary<string, EpgChannelData> _epgChannels = new(StringComparer.OrdinalIgnoreCase);
 31
 32    /// <summary>
 33    /// Initializes a new instance of the <see cref="ListingsManager"/> class.
 34    /// </summary>
 35    /// <param name="logger">The <see cref="ILogger{TCategoryName}"/>.</param>
 36    /// <param name="config">The <see cref="IConfigurationManager"/>.</param>
 37    /// <param name="taskManager">The <see cref="ITaskManager"/>.</param>
 38    /// <param name="tunerHostManager">The <see cref="ITunerHostManager"/>.</param>
 39    /// <param name="listingsProviders">The <see cref="IListingsProvider"/>.</param>
 40    public ListingsManager(
 41        ILogger<ListingsManager> logger,
 42        IConfigurationManager config,
 43        ITaskManager taskManager,
 44        ITunerHostManager tunerHostManager,
 45        IEnumerable<IListingsProvider> listingsProviders)
 46    {
 2847        _logger = logger;
 2848        _config = config;
 2849        _taskManager = taskManager;
 2850        _tunerHostManager = tunerHostManager;
 2851        _listingsProviders = listingsProviders.ToArray();
 2852    }
 53
 54    /// <inheritdoc />
 55    public async Task<ListingsProviderInfo> SaveListingProvider(ListingsProviderInfo info, bool validateLogin, bool vali
 56    {
 057        ArgumentNullException.ThrowIfNull(info);
 58
 059        var provider = GetProvider(info.Type);
 060        await provider.Validate(info, validateLogin, validateListings).ConfigureAwait(false);
 61
 062        var config = _config.GetLiveTvConfiguration();
 63
 064        var list = config.ListingProviders;
 065        int index = Array.FindIndex(list, i => string.Equals(i.Id, info.Id, StringComparison.OrdinalIgnoreCase));
 66
 067        if (index == -1 || string.IsNullOrWhiteSpace(info.Id))
 68        {
 069            info.Id = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
 070            config.ListingProviders = [.. list, info];
 71        }
 72        else
 73        {
 074            config.ListingProviders[index] = info;
 75        }
 76
 077        _config.SaveConfiguration("livetv", config);
 78
 079        InvalidateListingsProviderCache(info.Id);
 80
 081        _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
 82
 083        return info;
 084    }
 85
 86    /// <inheritdoc />
 87    public void DeleteListingsProvider(string? id)
 88    {
 189        var config = _config.GetLiveTvConfiguration();
 90
 191        config.ListingProviders = config.ListingProviders.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIg
 92
 193        _config.SaveConfiguration("livetv", config);
 94
 195        if (!string.IsNullOrEmpty(id))
 96        {
 197            InvalidateListingsProviderCache(id);
 98        }
 99
 1100        _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
 1101    }
 102
 103    /// <inheritdoc />
 104    public Task<List<NameIdPair>> GetLineups(string? providerType, string? providerId, string? country, string? location
 105    {
 0106        if (string.IsNullOrWhiteSpace(providerId))
 107        {
 0108            return GetProvider(providerType).GetLineups(null, country, location);
 109        }
 110
 0111        var info = _config.GetLiveTvConfiguration().ListingProviders
 0112            .FirstOrDefault(i => string.Equals(i.Id, providerId, StringComparison.OrdinalIgnoreCase))
 0113            ?? throw new ResourceNotFoundException();
 114
 0115        return GetProvider(info.Type).GetLineups(info, country, location);
 116    }
 117
 118    /// <inheritdoc />
 119    public async Task<IEnumerable<ProgramInfo>> GetProgramsAsync(
 120        ChannelInfo channel,
 121        DateTime startDateUtc,
 122        DateTime endDateUtc,
 123        CancellationToken cancellationToken)
 124    {
 0125        ArgumentNullException.ThrowIfNull(channel);
 126
 0127        foreach (var (provider, providerInfo) in GetListingProviders())
 128        {
 0129            if (!IsListingProviderEnabledForTuner(providerInfo, channel.TunerHostId))
 130            {
 0131                _logger.LogDebug(
 0132                    "Skipping getting programs for channel {0}-{1} from {2}-{3}, because it's not enabled for this tuner
 0133                    channel.Number,
 0134                    channel.Name,
 0135                    provider.Name,
 0136                    providerInfo.ListingsId ?? string.Empty);
 0137                continue;
 138            }
 139
 0140            _logger.LogDebug(
 0141                "Getting programs for channel {0}-{1} from {2}-{3}",
 0142                channel.Number,
 0143                channel.Name,
 0144                provider.Name,
 0145                providerInfo.ListingsId ?? string.Empty);
 146
 0147            var epgChannels = await GetEpgChannels(provider, providerInfo, true, cancellationToken).ConfigureAwait(false
 148
 0149            var epgChannel = GetEpgChannelFromTunerChannel(providerInfo.ChannelMappings, channel, epgChannels);
 0150            if (epgChannel is null)
 151            {
 0152                _logger.LogDebug("EPG channel not found for tuner channel {0}-{1} from {2}-{3}", channel.Number, channel
 0153                continue;
 154            }
 155
 0156            var programs = (await provider
 0157                .GetProgramsAsync(providerInfo, epgChannel.Id, startDateUtc, endDateUtc, cancellationToken).ConfigureAwa
 0158                .ToList();
 159
 160            // Replace the value that came from the provider with a normalized value
 0161            foreach (var program in programs)
 162            {
 0163                program.ChannelId = channel.Id;
 0164                program.Id += "_" + channel.Id;
 165            }
 166
 0167            if (programs.Count > 0)
 168            {
 0169                return programs;
 170            }
 0171        }
 172
 0173        return Enumerable.Empty<ProgramInfo>();
 0174    }
 175
 176    /// <inheritdoc />
 177    public async Task AddProviderMetadata(IList<ChannelInfo> channels, bool enableCache, CancellationToken cancellationT
 178    {
 0179        ArgumentNullException.ThrowIfNull(channels);
 180
 0181        foreach (var (provider, providerInfo) in GetListingProviders())
 182        {
 0183            var enabledChannels = channels
 0184                .Where(i => IsListingProviderEnabledForTuner(providerInfo, i.TunerHostId))
 0185                .ToList();
 186
 0187            if (enabledChannels.Count == 0)
 188            {
 189                continue;
 190            }
 191
 192            try
 193            {
 0194                await AddMetadata(provider, providerInfo, enabledChannels, enableCache, cancellationToken).ConfigureAwai
 0195            }
 0196            catch (NotSupportedException)
 197            {
 0198            }
 0199            catch (Exception ex)
 200            {
 0201                _logger.LogError(ex, "Error adding metadata");
 0202            }
 203        }
 0204    }
 205
 206    /// <inheritdoc />
 207    public async Task<ChannelMappingOptionsDto> GetChannelMappingOptions(string? providerId)
 208    {
 0209        var listingsProviderInfo = _config.GetLiveTvConfiguration().ListingProviders
 0210            .First(info => string.Equals(providerId, info.Id, StringComparison.OrdinalIgnoreCase));
 211
 0212        var provider = GetProvider(listingsProviderInfo.Type);
 213
 0214        var tunerChannels = await GetChannelsForListingsProvider(listingsProviderInfo, CancellationToken.None)
 0215            .ConfigureAwait(false);
 216
 0217        var providerChannels = await provider.GetChannels(listingsProviderInfo, default)
 0218            .ConfigureAwait(false);
 219
 0220        var mappings = listingsProviderInfo.ChannelMappings;
 221
 0222        return new ChannelMappingOptionsDto
 0223        {
 0224            TunerChannels = tunerChannels.Select(i => GetTunerChannelMapping(i, mappings, providerChannels)).ToList(),
 0225            ProviderChannels = providerChannels.Select(i => new NameIdPair
 0226            {
 0227                Name = i.Name,
 0228                Id = i.Id
 0229            }).ToList(),
 0230            Mappings = mappings,
 0231            ProviderName = provider.Name
 0232        };
 0233    }
 234
 235    /// <inheritdoc />
 236    public async Task<TunerChannelMapping> SetChannelMapping(string providerId, string tunerChannelNumber, string provid
 237    {
 0238        var config = _config.GetLiveTvConfiguration();
 239
 0240        var listingsProviderInfo = config.ListingProviders
 0241            .First(info => string.Equals(providerId, info.Id, StringComparison.OrdinalIgnoreCase));
 242
 0243        var channelMappingExists = listingsProviderInfo.ChannelMappings
 0244            .Any(pair => string.Equals(pair.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)
 0245                        && string.Equals(pair.Value, providerChannelNumber, StringComparison.OrdinalIgnoreCase));
 246
 0247        listingsProviderInfo.ChannelMappings = listingsProviderInfo.ChannelMappings
 0248            .Where(pair => !string.Equals(pair.Name, tunerChannelNumber, StringComparison.OrdinalIgnoreCase)).ToArray();
 249
 0250        if (!string.Equals(tunerChannelNumber, providerChannelNumber, StringComparison.OrdinalIgnoreCase)
 0251            && !channelMappingExists)
 252        {
 0253            var newItem = new NameValuePair
 0254            {
 0255                Name = tunerChannelNumber,
 0256                Value = providerChannelNumber
 0257            };
 0258            listingsProviderInfo.ChannelMappings = [.. listingsProviderInfo.ChannelMappings, newItem];
 259        }
 260
 0261        _config.SaveConfiguration("livetv", config);
 262
 0263        var tunerChannels = await GetChannelsForListingsProvider(listingsProviderInfo, CancellationToken.None)
 0264            .ConfigureAwait(false);
 265
 0266        var providerChannels = await GetProvider(listingsProviderInfo.Type).GetChannels(listingsProviderInfo, default)
 0267            .ConfigureAwait(false);
 268
 0269        var tunerChannelMappings = tunerChannels
 0270            .Select(i => GetTunerChannelMapping(i, listingsProviderInfo.ChannelMappings, providerChannels)).ToList();
 271
 0272        _taskManager.CancelIfRunningAndQueue<RefreshGuideScheduledTask>();
 273
 0274        return tunerChannelMappings.First(i => string.Equals(i.Id, tunerChannelNumber, StringComparison.OrdinalIgnoreCas
 0275    }
 276
 277    private List<(IListingsProvider Provider, ListingsProviderInfo ProviderInfo)> GetListingProviders()
 0278        => _config.GetLiveTvConfiguration().ListingProviders
 0279            .Select(info => (
 0280                Provider: _listingsProviders.FirstOrDefault(l
 0281                    => string.Equals(l.Type, info.Type, StringComparison.OrdinalIgnoreCase)),
 0282                ProviderInfo: info))
 0283            .Where(i => i.Provider is not null)
 0284            .ToList()!; // Already filtered out null
 285
 286    private async Task AddMetadata(
 287        IListingsProvider provider,
 288        ListingsProviderInfo info,
 289        IEnumerable<ChannelInfo> tunerChannels,
 290        bool enableCache,
 291        CancellationToken cancellationToken)
 292    {
 0293        var epgChannels = await GetEpgChannels(provider, info, enableCache, cancellationToken).ConfigureAwait(false);
 294
 0295        foreach (var tunerChannel in tunerChannels)
 296        {
 0297            var epgChannel = GetEpgChannelFromTunerChannel(info.ChannelMappings, tunerChannel, epgChannels);
 0298            if (epgChannel is null)
 299            {
 300                continue;
 301            }
 302
 0303            if (!string.IsNullOrWhiteSpace(epgChannel.ImageUrl))
 304            {
 0305                tunerChannel.ImageUrl = epgChannel.ImageUrl;
 306            }
 307        }
 0308    }
 309
 310    private static bool IsListingProviderEnabledForTuner(ListingsProviderInfo info, string tunerHostId)
 311    {
 0312        if (info.EnableAllTuners)
 313        {
 0314            return true;
 315        }
 316
 0317        ArgumentException.ThrowIfNullOrWhiteSpace(tunerHostId);
 318
 0319        return info.EnabledTuners.Contains(tunerHostId, StringComparer.OrdinalIgnoreCase);
 320    }
 321
 322    private static string GetMappedChannel(string channelId, NameValuePair[] mappings)
 323    {
 0324        foreach (NameValuePair mapping in mappings)
 325        {
 0326            if (string.Equals(mapping.Name, channelId, StringComparison.OrdinalIgnoreCase))
 327            {
 0328                return mapping.Value;
 329            }
 330        }
 331
 0332        return channelId;
 333    }
 334
 335    private void InvalidateListingsProviderCache(string providerId)
 336    {
 337        // Clear in-memory EPG channel cache for this provider
 1338        _epgChannels.TryRemove(providerId, out _);
 339
 340        // Provider IDs are generated as Guid.NewGuid().ToString("N")
 341        // reject anything else so we never use untrusted input in a path or log entry.
 1342        if (!Guid.TryParseExact(providerId, "N", out var providerGuid))
 343        {
 1344            return;
 345        }
 346
 347        // Delete the cached XMLTV file so a fresh copy is downloaded
 0348        var cachePath = _config.CommonApplicationPaths?.CachePath;
 0349        if (!string.IsNullOrEmpty(cachePath))
 350        {
 0351            var safeId = providerGuid.ToString("N", CultureInfo.InvariantCulture);
 0352            var xmltvCacheFile = Path.Combine(cachePath, "xmltv", safeId + ".xml");
 353            try
 354            {
 0355                if (File.Exists(xmltvCacheFile))
 356                {
 0357                    File.Delete(xmltvCacheFile);
 358                }
 0359            }
 0360            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
 361            {
 0362                _logger.LogWarning(ex, "Error deleting XMLTV cache file for provider {ProviderId}", safeId);
 0363            }
 364        }
 0365    }
 366
 367    private async Task<EpgChannelData> GetEpgChannels(
 368        IListingsProvider provider,
 369        ListingsProviderInfo info,
 370        bool enableCache,
 371        CancellationToken cancellationToken)
 372    {
 0373        if (enableCache && _epgChannels.TryGetValue(info.Id, out var result))
 374        {
 0375            return result;
 376        }
 377
 0378        var channels = await provider.GetChannels(info, cancellationToken).ConfigureAwait(false);
 0379        foreach (var channel in channels)
 380        {
 0381            _logger.LogInformation("Found epg channel in {0} {1} {2} {3}", provider.Name, info.ListingsId, channel.Name,
 382        }
 383
 0384        result = new EpgChannelData(channels);
 0385        _epgChannels.AddOrUpdate(info.Id, result, (_, _) => result);
 386
 0387        return result;
 0388    }
 389
 390    private static ChannelInfo? GetEpgChannelFromTunerChannel(
 391        NameValuePair[] mappings,
 392        ChannelInfo tunerChannel,
 393        EpgChannelData epgChannelData)
 394    {
 0395        if (!string.IsNullOrWhiteSpace(tunerChannel.Id))
 396        {
 0397            var mappedTunerChannelId = GetMappedChannel(tunerChannel.Id, mappings);
 0398            if (string.IsNullOrWhiteSpace(mappedTunerChannelId))
 399            {
 0400                mappedTunerChannelId = tunerChannel.Id;
 401            }
 402
 0403            var channel = epgChannelData.GetChannelById(mappedTunerChannelId);
 0404            if (channel is not null)
 405            {
 0406                return channel;
 407            }
 408        }
 409
 0410        if (!string.IsNullOrWhiteSpace(tunerChannel.TunerChannelId))
 411        {
 0412            var tunerChannelId = tunerChannel.TunerChannelId;
 0413            if (tunerChannelId.Contains(".json.schedulesdirect.org", StringComparison.OrdinalIgnoreCase))
 414            {
 0415                tunerChannelId = tunerChannelId.Replace(".json.schedulesdirect.org", string.Empty, StringComparison.Ordi
 416            }
 417
 0418            var mappedTunerChannelId = GetMappedChannel(tunerChannelId, mappings);
 0419            if (string.IsNullOrWhiteSpace(mappedTunerChannelId))
 420            {
 0421                mappedTunerChannelId = tunerChannelId;
 422            }
 423
 0424            var channel = epgChannelData.GetChannelById(mappedTunerChannelId);
 0425            if (channel is not null)
 426            {
 0427                return channel;
 428            }
 429        }
 430
 0431        if (!string.IsNullOrWhiteSpace(tunerChannel.Number))
 432        {
 0433            var tunerChannelNumber = GetMappedChannel(tunerChannel.Number, mappings);
 0434            if (string.IsNullOrWhiteSpace(tunerChannelNumber))
 435            {
 0436                tunerChannelNumber = tunerChannel.Number;
 437            }
 438
 0439            var channel = epgChannelData.GetChannelByNumber(tunerChannelNumber);
 0440            if (channel is not null)
 441            {
 0442                return channel;
 443            }
 444        }
 445
 0446        if (!string.IsNullOrWhiteSpace(tunerChannel.Name))
 447        {
 0448            var normalizedName = EpgChannelData.NormalizeName(tunerChannel.Name);
 449
 0450            var channel = epgChannelData.GetChannelByName(normalizedName);
 0451            if (channel is not null)
 452            {
 0453                return channel;
 454            }
 455        }
 456
 0457        return null;
 458    }
 459
 460    private static TunerChannelMapping GetTunerChannelMapping(ChannelInfo tunerChannel, NameValuePair[] mappings, IList<
 461    {
 0462        var result = new TunerChannelMapping
 0463        {
 0464            Name = tunerChannel.Name,
 0465            Id = tunerChannel.Id
 0466        };
 467
 0468        if (!string.IsNullOrWhiteSpace(tunerChannel.Number))
 469        {
 0470            result.Name = tunerChannel.Number + " " + result.Name;
 471        }
 472
 0473        var providerChannel = GetEpgChannelFromTunerChannel(mappings, tunerChannel, new EpgChannelData(providerChannels)
 0474        if (providerChannel is not null)
 475        {
 0476            result.ProviderChannelName = providerChannel.Name;
 0477            result.ProviderChannelId = providerChannel.Id;
 478        }
 479
 0480        return result;
 481    }
 482
 483    private async Task<List<ChannelInfo>> GetChannelsForListingsProvider(ListingsProviderInfo info, CancellationToken ca
 484    {
 0485        var channels = new List<ChannelInfo>();
 0486        foreach (var hostInstance in _tunerHostManager.TunerHosts)
 487        {
 488            try
 489            {
 0490                var tunerChannels = await hostInstance.GetChannels(false, cancellationToken).ConfigureAwait(false);
 491
 0492                channels.AddRange(tunerChannels.Where(channel => IsListingProviderEnabledForTuner(info, channel.TunerHos
 0493            }
 0494            catch (Exception ex)
 495            {
 0496                _logger.LogError(ex, "Error getting channels");
 0497            }
 498        }
 499
 0500        return channels;
 0501    }
 502
 503    private IListingsProvider GetProvider(string? providerType)
 0504        => _listingsProviders.FirstOrDefault(i => string.Equals(providerType, i.Type, StringComparison.OrdinalIgnoreCase
 0505           ?? throw new ResourceNotFoundException($"Couldn't find provider of type {providerType}");
 506}