< 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: 96
Coverable lines: 99
Total lines: 342
Line coverage: 3%
Branch coverage
0%
Covered branches: 0
Total branches: 80
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 12/28/2025 - 12:10:13 AM Line coverage: 3.2% (3/93) Branch coverage: 0% (0/70) Total lines: 3254/7/2026 - 12:14:03 AM Line coverage: 3% (3/99) Branch coverage: 0% (0/80) Total lines: 342 12/28/2025 - 12:10:13 AM Line coverage: 3.2% (3/93) Branch coverage: 0% (0/70) Total lines: 3254/7/2026 - 12:14:03 AM Line coverage: 3% (3/99) Branch coverage: 0% (0/80) Total lines: 342

Coverage delta

Coverage delta 1 -1

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetChannelInfo(...)0%210140%
GetChannelNumber(...)0%1190340%
IsValidChannelUrl(...)0%110100%
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                    if (!IsValidChannelUrl(trimmedLine))
 97                    {
 98                        _logger.LogWarning("Skipping M3U channel entry with non-HTTP path: {Path}", trimmedLine);
 99                        extInf = string.Empty;
 100                        continue;
 101                    }
 102
 103                    var channel = GetChannelInfo(extInf, tunerHostId, trimmedLine);
 104                    channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture);
 105
 106                    channel.Path = trimmedLine;
 107                    channels.Add(channel);
 108                    _logger.LogInformation("Parsed channel: {ChannelName}", channel.Name);
 109                    extInf = string.Empty;
 110                }
 111            }
 112
 113            return channels;
 114        }
 115
 116        private ChannelInfo GetChannelInfo(string extInf, string tunerHostId, string mediaUrl)
 117        {
 0118            var channel = new ChannelInfo()
 0119            {
 0120                TunerHostId = tunerHostId
 0121            };
 122
 0123            extInf = extInf.Trim();
 124
 0125            var attributes = ParseExtInf(extInf, out string remaining);
 0126            extInf = remaining;
 127
 0128            if (attributes.TryGetValue("tvg-logo", out string tvgLogo))
 129            {
 0130                channel.ImageUrl = tvgLogo;
 131            }
 0132            else if (attributes.TryGetValue("logo", out string logo))
 133            {
 0134                channel.ImageUrl = logo;
 135            }
 136
 0137            if (attributes.TryGetValue("group-title", out string groupTitle))
 138            {
 0139                channel.ChannelGroup = groupTitle;
 140            }
 141
 0142            channel.Name = GetChannelName(extInf, attributes);
 0143            channel.Number = GetChannelNumber(extInf, attributes, mediaUrl);
 144
 0145            attributes.TryGetValue("tvg-id", out string tvgId);
 146
 0147            attributes.TryGetValue("channel-id", out string channelId);
 148
 0149            channel.TunerChannelId = string.IsNullOrWhiteSpace(tvgId) ? channelId : tvgId;
 150
 0151            var channelIdValues = new List<string>();
 0152            if (!string.IsNullOrWhiteSpace(channelId))
 153            {
 0154                channelIdValues.Add(channelId);
 155            }
 156
 0157            if (!string.IsNullOrWhiteSpace(tvgId))
 158            {
 0159                channelIdValues.Add(tvgId);
 160            }
 161
 0162            if (channelIdValues.Count > 0)
 163            {
 0164                channel.Id = string.Join('_', channelIdValues);
 165            }
 166
 0167            return channel;
 168        }
 169
 170        private string GetChannelNumber(string extInf, Dictionary<string, string> attributes, string mediaUrl)
 171        {
 0172            var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries);
 0173            var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].AsSpan().Trim() : ReadOnlySpan<char>.Empty;
 174
 0175            string numberString = null;
 176
 0177            if (attributes.TryGetValue("tvg-chno", out var attributeValue)
 0178                && double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
 179            {
 0180                numberString = attributeValue;
 181            }
 182
 0183            if (!IsValidChannelNumber(numberString))
 184            {
 0185                if (attributes.TryGetValue("tvg-id", out attributeValue))
 186                {
 0187                    if (double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
 188                    {
 0189                        numberString = attributeValue;
 190                    }
 0191                    else if (attributes.TryGetValue("channel-id", out attributeValue)
 0192                        && double.TryParse(attributeValue, CultureInfo.InvariantCulture, out _))
 193                    {
 0194                        numberString = attributeValue;
 195                    }
 196                }
 197
 0198                if (string.IsNullOrWhiteSpace(numberString))
 199                {
 200                    // Using this as a fallback now as this leads to Problems with channels like "5 USA"
 201                    // where 5 isn't meant to be the channel number
 202                    // Check for channel number with the format from SatIp
 203                    // #EXTINF:0,84. VOX Schweiz
 204                    // #EXTINF:0,84.0 - VOX Schweiz
 0205                    if (!nameInExtInf.IsEmpty && !nameInExtInf.IsWhiteSpace())
 206                    {
 0207                        var numberIndex = nameInExtInf.IndexOf(' ');
 0208                        if (numberIndex > 0)
 209                        {
 0210                            var numberPart = nameInExtInf[..numberIndex].Trim(stackalloc[] { ' ', '.' });
 0211                            if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
 212                            {
 0213                                numberString = numberPart.ToString();
 214                            }
 215                        }
 216                    }
 217                }
 218            }
 219
 0220            if (!IsValidChannelNumber(numberString))
 221            {
 0222                numberString = null;
 223            }
 224
 0225            if (!string.IsNullOrWhiteSpace(numberString))
 226            {
 0227                numberString = numberString.Trim();
 228            }
 229            else
 230            {
 0231                if (string.IsNullOrWhiteSpace(mediaUrl))
 232                {
 0233                    numberString = null;
 234                }
 235                else
 236                {
 237                    try
 238                    {
 0239                        numberString = Path.GetFileNameWithoutExtension(mediaUrl.AsSpan().RightPart('/')).ToString();
 240
 0241                        if (!IsValidChannelNumber(numberString))
 242                        {
 0243                            numberString = null;
 244                        }
 0245                    }
 0246                    catch
 247                    {
 248                        // Seeing occasional argument exception here
 0249                        numberString = null;
 0250                    }
 251                }
 252            }
 253
 0254            return numberString;
 255        }
 256
 257        private static bool IsValidChannelUrl(string url)
 258        {
 0259            return Uri.TryCreate(url, UriKind.Absolute, out var uri)
 0260                && (string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase)
 0261                    || string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)
 0262                    || string.Equals(uri.Scheme, "rtsp", StringComparison.OrdinalIgnoreCase)
 0263                    || string.Equals(uri.Scheme, "rtp", StringComparison.OrdinalIgnoreCase)
 0264                    || string.Equals(uri.Scheme, "udp", StringComparison.OrdinalIgnoreCase));
 265        }
 266
 267        private static bool IsValidChannelNumber(string numberString)
 268        {
 0269            if (string.IsNullOrWhiteSpace(numberString)
 0270                || string.Equals(numberString, "-1", StringComparison.Ordinal)
 0271                || string.Equals(numberString, "0", StringComparison.Ordinal))
 272            {
 0273                return false;
 274            }
 275
 0276            return double.TryParse(numberString, CultureInfo.InvariantCulture, out _);
 277        }
 278
 279        private static string GetChannelName(string extInf, Dictionary<string, string> attributes)
 280        {
 0281            var nameParts = extInf.Split(',', StringSplitOptions.RemoveEmptyEntries);
 0282            var nameInExtInf = nameParts.Length > 1 ? nameParts[^1].Trim() : null;
 283
 284            // Check for channel number with the format from SatIp
 285            // #EXTINF:0,84. VOX Schweiz
 286            // #EXTINF:0,84.0 - VOX Schweiz
 0287            if (!string.IsNullOrWhiteSpace(nameInExtInf))
 288            {
 0289                var numberIndex = nameInExtInf.IndexOf(' ', StringComparison.Ordinal);
 0290                if (numberIndex > 0)
 291                {
 0292                    var numberPart = nameInExtInf.AsSpan(0, numberIndex).Trim(stackalloc[] { ' ', '.' });
 293
 0294                    if (double.TryParse(numberPart, CultureInfo.InvariantCulture, out _))
 295                    {
 296                        // channel.Number = number.ToString();
 0297                        nameInExtInf = nameInExtInf.AsSpan(numberIndex + 1).Trim(stackalloc[] { ' ', '-' }).ToString();
 298                    }
 299                }
 300            }
 301
 0302            string name = nameInExtInf;
 303
 0304            if (string.IsNullOrWhiteSpace(name))
 305            {
 0306                attributes.TryGetValue("tvg-name", out name);
 307            }
 308
 0309            if (string.IsNullOrWhiteSpace(name))
 310            {
 0311                attributes.TryGetValue("tvg-id", out name);
 312            }
 313
 0314            if (string.IsNullOrWhiteSpace(name))
 315            {
 0316                name = null;
 317            }
 318
 0319            return name;
 320        }
 321
 322        private static Dictionary<string, string> ParseExtInf(string line, out string remaining)
 323        {
 0324            var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
 325
 0326            var matches = KeyValueRegex().Matches(line);
 327
 0328            remaining = line;
 329
 0330            foreach (Match match in matches)
 331            {
 0332                var key = match.Groups[1].Value;
 0333                var value = match.Groups[2].Value;
 334
 0335                dict[key] = value;
 0336                remaining = remaining.Replace(key + "=\"" + value + "\"", string.Empty, StringComparison.OrdinalIgnoreCa
 337            }
 338
 0339            return dict;
 340        }
 341    }
 342}