diff --git a/LoggingEvents.cs b/LoggingEvents.cs index 8d02d22..d582702 100644 --- a/LoggingEvents.cs +++ b/LoggingEvents.cs @@ -4,6 +4,7 @@ namespace Tv7Playlist { public const int Startup = 1000; public const int Playlist = 1001; + public const int ParsingM3uPlayList = 1002; public const int PlaylistNotFound = 4000; } diff --git a/Parser/IPlaylistParser.cs b/Parser/IPlaylistParser.cs new file mode 100644 index 0000000..80ab481 --- /dev/null +++ b/Parser/IPlaylistParser.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +namespace Tv7Playlist.Parser +{ + public interface IPlaylistParser + { + Task> ParseFromStream(Stream stream); + } +} \ No newline at end of file diff --git a/Parser/M3UParser.cs b/Parser/M3UParser.cs new file mode 100644 index 0000000..a964358 --- /dev/null +++ b/Parser/M3UParser.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore.Internal; +using Microsoft.Extensions.Logging; + +namespace Tv7Playlist.Parser +{ + internal class M3UParser : IPlaylistParser + { + private const string ExtInfStartTag = "#EXTINF:"; + private const string ExtFileStartTag = "#EXTM3U"; + + private readonly ILogger _logger; + + public M3UParser(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task> ParseFromStream(Stream stream) + { + if (stream == null) throw new ArgumentNullException(nameof(stream)); + + _logger.LogInformation(LoggingEvents.ParsingM3uPlayList, "Parsing m3u file content"); + + EnsureStreamIsAtBeginning(stream); + + using (var reader = new StreamReader(stream)) + { + var tracks = await ParseTracksFromStreamAsync(reader); + + _logger.LogInformation(LoggingEvents.ParsingM3uPlayList, "Parsing m3u file finished"); + + return tracks; + } + } + + private async Task> ParseTracksFromStreamAsync(StreamReader reader) + { + if (reader == null) throw new ArgumentNullException(nameof(reader)); + + if (!await StreamHasValidStartTagAsync(reader)) + { + _logger.LogError(LoggingEvents.ParsingM3uPlayList, $"Could not parse stream as it did not start with {ExtFileStartTag}"); + return new List(300); + } + + var tracks = new List(300); + var currentId = 1000; + + while (!reader.EndOfStream) + { + await ParseTracksAsync(reader, tracks, currentId); + currentId++; + } + + return tracks; + } + + private static async Task StreamHasValidStartTagAsync(StreamReader reader) + { + var startLine = await ReadLineSafeAsync(reader); + var stramHasValidStartTag = !startLine.Trim().ToUpper().Equals(ExtInfStartTag); + return stramHasValidStartTag; + } + + private void EnsureStreamIsAtBeginning(Stream stream) + { + if (stream.Position == 0) return; + + _logger.LogWarning(LoggingEvents.ParsingM3uPlayList, "Stream not positioned at the beginning. Repositioning!"); + stream.Position = 0; + } + + private async Task ParseTracksAsync(TextReader reader, ICollection tracks, int currentId) + { + if (reader == null) throw new ArgumentNullException(nameof(reader)); + if (tracks == null) throw new ArgumentNullException(nameof(tracks)); + if (currentId <= 0) throw new ArgumentOutOfRangeException(nameof(currentId)); + + var metaLine = await ReadLineSafeAsync(reader); + if (!metaLine.StartsWith(ExtInfStartTag)) + { + _logger.LogDebug(LoggingEvents.ParsingM3uPlayList, + "Line {lineNumber} {metaLine} is not a valid start channel start line", + currentId.ToString(), metaLine); + return; + } + + var url = await ReadLineSafeAsync(reader); + var track = CreateTrack(currentId, metaLine, url); + if (track != null) + { + tracks.Add(track); + _logger.LogDebug(LoggingEvents.ParsingM3uPlayList, "Parsed track {track}", track); + } + else + { + _logger.LogWarning(LoggingEvents.ParsingM3uPlayList, + "Could not parse lines {metaLine} with url {url}", metaLine, url); + } + } + + private static async Task ReadLineSafeAsync(TextReader reader) + { + if (reader == null) throw new ArgumentNullException(nameof(reader)); + + var line = await reader.ReadLineAsync(); + + return line ?? string.Empty; + } + + private ParsedTrack CreateTrack(int currentId, string metaLine, string url) + { + if (string.IsNullOrWhiteSpace(metaLine)) + return null; + + if (string.IsNullOrWhiteSpace(url)) + return null; + + var fields = metaLine.Replace(ExtInfStartTag, string.Empty).Split(","); + var name = fields.Length >= 2 ? fields[1] : $"{currentId}-unknown"; + + return new ParsedTrack(currentId, name, url); + } + } +} \ No newline at end of file diff --git a/Parser/ParsedTrack.cs b/Parser/ParsedTrack.cs new file mode 100644 index 0000000..f28faa6 --- /dev/null +++ b/Parser/ParsedTrack.cs @@ -0,0 +1,51 @@ +using System; +using System.Diagnostics; + +namespace Tv7Playlist.Parser +{ + [DebuggerDisplay("ParsedTrack-{Id}(Name:{Name}, Url:{Url})")] + public class ParsedTrack + { + public ParsedTrack(int id, string name, string url) + { + Id = id; + Name = name; + Url = url ?? throw new ArgumentNullException(nameof(url)); + } + + public int Id { get; } + + public string Name { get; } + + public string Url { get; } + + protected bool Equals(ParsedTrack other) + { + return Id == other.Id && string.Equals(Name, other.Name) && string.Equals(Url, other.Url); + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(null, obj)) return false; + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + return Equals((ParsedTrack) obj); + } + + public override int GetHashCode() + { + unchecked + { + var hashCode = Id; + hashCode = (hashCode * 397) ^ (Name != null ? Name.GetHashCode() : 0); + hashCode = (hashCode * 397) ^ (Url != null ? Url.GetHashCode() : 0); + return hashCode; + } + } + + public override string ToString() + { + return $"{nameof(Id)}: {Id}, {nameof(Name)}: {Name}, {nameof(Url)}: {Url}"; + } + } +} \ No newline at end of file