< Summary - Jellyfin

Information
Class: Jellyfin.LiveTv.TunerHosts.M3uParser
Assembly: Jellyfin.LiveTv
File(s): /srv/git/jellyfin/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs
Line coverage
3%
Covered lines: 3
Uncovered lines: 90
Coverable lines: 93
Total lines: 326
Line coverage: 3.2%
Branch coverage
0%
Covered branches: 0
Total branches: 70
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetChannelnfo(...)0%210140%
GetChannelNumber(...)0%1190340%
IsValidChannelNumber(...)0%4260%
GetChannelName(...)0%210140%
ParseExtInf(...)0%620%

File(s)

/srv/git/jellyfin/src/Jellyfin.LiveTv/TunerHosts/M3uParser.cs

#LineLine coverage
 1#nullable disable
 2
 3#pragma warning disable CS1591
 4
 5using System;
 6using System.Collections.Generic;
 7using System.Globalization;
 8using System.IO;
 9using System.Net.Http;
 10using System.Text.RegularExpressions;
 11using System.Threading;
 12using System.Threading.Tasks;
 13using Jellyfin.Extensions;
 14using MediaBrowser.Common.Extensions;
 15using MediaBrowser.Common.Net;
 16using MediaBrowser.Controller.LiveTv;
 17using MediaBrowser.Model.IO;
 18using MediaBrowser.Model.LiveTv;
 19using Microsoft.Extensions.Logging;
 20
 21namespace Jellyfin.LiveTv.TunerHosts
 22{
 23    public partial class M3uParser
 24    {
 25        private const string ExtInfPrefix = "#EXTINF:";
 26
 27        private readonly ILogger _logger;
 28        private readonly IHttpClientFactory _httpClientFactory;
 29
 30        public M3uParser(ILogger logger, IHttpClientFactory httpClientFactory)
 31        {
 232            _logger = logger;
 233            _httpClientFactory = httpClientFactory;
 234        }
 35
 36        [GeneratedRegex(@"([a-z0-9\-_]+)=\""([^""]+)\""", RegexOptions.IgnoreCase, "en-US")]
 37        private static partial Regex KeyValueRegex();
 38
 39        public async Task<List<ChannelInfo>> Parse(TunerHostInfo info, string channelIdPrefix, CancellationToken cancell
 40        {
 41            // Read the file and display it line by line.
 42            using (var reader = new StreamReader(await GetListingsStream(info, cancellationToken).ConfigureAwait(false))
 43            {
 44                return await GetChannelsAsync(reader, channelIdPrefix, info.Id).ConfigureAwait(false);
 45            }
 46        }
 47
 48        public async Task<Stream> GetListingsStream(TunerHostInfo info, CancellationToken cancellationToken)
 49        {
 50            ArgumentNullException.ThrowIfNull(info);
 51
 52            if (!info.Url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
 53            {
 54                return AsyncFile.OpenRead(info.Url);
 55            }
 56
 57            using var requestMessage = new HttpRequestMessage(HttpMethod.Get, info.Url);
 58            if (!string.IsNullOrEmpty(info.UserAgent))
 59            {
 60                requestMessage.Headers.UserAgent.TryParseAdd(info.UserAgent);
 61            }
 62
 63            // Set HttpCompletionOption.ResponseHeadersRead to prevent timeouts on larger files
 64            var response = await _httpClientFactory.CreateClient(NamedClient.Default)
 65                .SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
 66                .ConfigureAwait(false);
 67            response.EnsureSuccessStatusCode();
 68
 69            return await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
 70        }
 71
 72        private async Task<List<ChannelInfo>> GetChannelsAsync(TextReader reader, string channelIdPrefix, string tunerHo
 73        {
 74            var channels = new List<ChannelInfo>();
 75            string extInf = string.Empty;
 76
 77            await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false))
 78            {
 79                var trimmedLine = line.Trim();
 80                if (string.IsNullOrWhiteSpace(trimmedLine))
 81                {
 82                    continue;
 83                }
 84
 85                if (trimmedLine.StartsWith("#EXTM3U", StringComparison.OrdinalIgnoreCase))
 86                {
 87                    continue;
 88                }
 89
 90                if (trimmedLine.StartsWith(ExtInfPrefix, StringComparison.OrdinalIgnoreCase))
 91                {
 92                    extInf = trimmedLine.Substring(ExtInfPrefix.Length).Trim();
 93                }
 94                else if (!string.IsNullOrWhiteSpace(extInf) && !trimmedLine.StartsWith('#'))
 95                {
 96                    var channel = GetChannelnfo(extInf, tunerHostId, trimmedLine);
 97                    channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture);
 98
 99                    channel.Path = trimmedLine;
 100                    channels.Add(channel);
 101                    _logger.LogInformation("Parsed channel: {ChannelName}", channel.Name);
 102                    extInf = string.Empty;
 103                }
 104            }
 105
 106            return channels;
 107        }
 108
 109        private ChannelInfo GetChannelnfo(string extInf, string tunerHostId, string mediaUrl)
 110        {
 0111            var channel = new ChannelInfo()
 0112            {
 0113                TunerHostId = tunerHostId
 0114            };
 115
 0116            extInf = extInf.Trim();
 117
 0118            var attributes = ParseExtInf(extInf, out string remaining);
 0119            extInf = remaining;
 120
 0121            if (attributes.TryGetValue("tvg-logo", out string tvgLogo))
 122            {
 0123                channel.ImageUrl = tvgLogo;
 124            }
 0125            else if (attributes.TryGetValue("logo", out string logo))
 126            {
 0127                channel.ImageUrl = logo;
 128            }
 129
 0130            if (attributes.TryGetValue("group-title", out string groupTitle))
 131            {
 0132                channel.ChannelGroup = groupTitle;
 133            }
 134
 0135            channel.Name = GetChannelName(extInf, attributes);
 0136            channel.Number = GetChannelNumber(extInf, attributes, mediaUrl);
 137
 0138            attributes.TryGetValue("tvg-id", out string tvgId);
 139
 0140            attributes.TryGetValue("channel-id", out string channelId);
 141
 0142            channel.TunerChannelId = string.IsNullOrWhiteSpace(tvgId) ? channelId : tvgId;
 143
 0144            var channelIdValues = new List<string>();
 0145            if (!string.IsNullOrWhiteSpace(channelId))
 146            {
 0147                channelIdValues.Add(channelId);
 148            }
 149
 0150            if (!string.IsNullOrWhiteSpace(tvgId))
 151            {
 0152                channelIdValues.Add(tvgId);
 153            }
 154
 0155            if (channelIdValues.Count > 0)
 156            {
 0157                channel.Id = string.Join('_', channelIdValues);
 158            }
 159
 0160            return channel;
 161        }
 162
 163        private string GetChannelNumber(string extInf, Dictionary<string, string> attributes, string mediaUrl)
 164        {
 0165            var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries);
 0166            var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].AsSpan().Trim() : ReadOnlySpan<char>.Empty;
 167
 0168            string numberString = null;
 169
 0170            if (attributes.TryGetValue("tvg-chno", out var attributeValue)
 0171                && double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
 172            {
 0173                numberString = attributeValue;
 174            }
 175
 0176            if (!IsValidChannelNumber(numberString))
 177            {
 0178                if (attributes.TryGetValue("tvg-id", out attributeValue))
 179                {
 0180                    if (double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
 181                    {
 0182                        numberString = attributeValue;
 183                    }
 0184                    else if (attributes.TryGetValue("channel-id", out attributeValue)
 0185                        && double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
 186                    {
 0187                        numberString = attributeValue;
 188                    }
 189                }
 190
 0191                if (string.IsNullOrWhiteSpace(numberString))
 192                {
 193                    // Using this as a fallback now as this leads to Problems with channels like "5 USA"
 194                    // where 5 isn't meant to be the channel number
 195                    // Check for channel number with the format from SatIp
 196                    // #EXTINF:0,84. VOX Schweiz
 197                    // #EXTINF:0,84.0 - VOX Schweiz
 0198                    if (!nameInExtInf.IsEmpty && !nameInExtInf.IsWhiteSpace())
 199                    {
 0200                        var numberIndex = nameInExtInf.IndexOf(' ');
 0201                        if (numberIndex > 0)
 202                        {
 0203                            var numberPart = nameInExtInf.Slice(0, numberIndex).Trim(new[] { ' ', '.' });
 204
 0205                            if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
 206                            {
 0207                                numberString = numberPart.ToString();
 208                            }
 209                        }
 210                    }
 211                }
 212            }
 213
 0214            if (!IsValidChannelNumber(numberString))
 215            {
 0216                numberString = null;
 217            }
 218
 0219            if (!string.IsNullOrWhiteSpace(numberString))
 220            {
 0221                numberString = numberString.Trim();
 222            }
 223            else
 224            {
 0225                if (string.IsNullOrWhiteSpace(mediaUrl))
 226                {
 0227                    numberString = null;
 228                }
 229                else
 230                {
 231                    try
 232                    {
 0233                        numberString = Path.GetFileNameWithoutExtension(mediaUrl.AsSpan().RightPart('/')).ToString();
 234
 0235                        if (!IsValidChannelNumber(numberString))
 236                        {
 0237                            numberString = null;
 238                        }
 0239                    }
 0240                    catch
 241                    {
 242                        // Seeing occasional argument exception here
 0243                        numberString = null;
 0244                    }
 245                }
 246            }
 247
 0248            return numberString;
 249        }
 250
 251        private static bool IsValidChannelNumber(string numberString)
 252        {
 0253            if (string.IsNullOrWhiteSpace(numberString)
 0254                || string.Equals(numberString, "-1", StringComparison.Ordinal)
 0255                || string.Equals(numberString, "0", StringComparison.Ordinal))
 256            {
 0257                return false;
 258            }
 259
 0260            return double.TryParse(numberString, CultureInfo.InvariantCulture, out _);
 261        }
 262
 263        private static string GetChannelName(string extInf, Dictionary<string, string> attributes)
 264        {
 0265            var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries);
 0266            var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].Trim() : null;
 267
 268            // Check for channel number with the format from SatIp
 269            // #EXTINF:0,84. VOX Schweiz
 270            // #EXTINF:0,84.0 - VOX Schweiz
 0271            if (!string.IsNullOrWhiteSpace(nameInExtInf))
 272            {
 0273                var numberIndex = nameInExtInf.IndexOf(' ', StringComparison.Ordinal);
 0274                if (numberIndex > 0)
 275                {
 0276                    var numberPart = nameInExtInf.AsSpan(0, numberIndex).Trim(new[] { ' ', '.' });
 277
 0278                    if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
 279                    {
 280                        // channel.Number = number.ToString();
 0281                        nameInExtInf = nameInExtInf.AsSpan(numberIndex + 1).Trim(new[] { ' ', '-' }).ToString();
 282                    }
 283                }
 284            }
 285
 0286            string name = nameInExtInf;
 287
 0288            if (string.IsNullOrWhiteSpace(name))
 289            {
 0290                attributes.TryGetValue("tvg-name", out name);
 291            }
 292
 0293            if (string.IsNullOrWhiteSpace(name))
 294            {
 0295                attributes.TryGetValue("tvg-id", out name);
 296            }
 297
 0298            if (string.IsNullOrWhiteSpace(name))
 299            {
 0300                name = null;
 301            }
 302
 0303            return name;
 304        }
 305
 306        private static Dictionary<string, string> ParseExtInf(string line, out string remaining)
 307        {
 0308            var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 309
 0310            var matches = KeyValueRegex().Matches(line);
 311
 0312            remaining = line;
 313
 0314            foreach (Match match in matches)
 315            {
 0316                var key = match.Groups[1].Value;
 0317                var value = match.Groups[2].Value;
 318
 0319                dict[key] = value;
 0320                remaining = remaining.Replace(key + "=\"" + value + "\"", string.Empty, StringComparison.OrdinalIgnoreCa
 321            }
 322
 0323            return dict;
 324        }
 325    }
 326}