diff --git a/TV Player Avalonia/TV Player Avalonia.csproj b/TV Player Avalonia/TV Player Avalonia.csproj index 473f389..966cac9 100644 --- a/TV Player Avalonia/TV Player Avalonia.csproj +++ b/TV Player Avalonia/TV Player Avalonia.csproj @@ -23,11 +23,6 @@ - - - - - - + diff --git a/TV Player Core/GroupInfo.cs b/TV Player Core/GroupInfo.cs new file mode 100644 index 0000000..f8a3da8 --- /dev/null +++ b/TV Player Core/GroupInfo.cs @@ -0,0 +1,9 @@ +namespace TV_Player +{ + [Serializable] + public class GroupInfo + { + public string Name { get; set; } + public int Count { get; set; } + } +} diff --git a/TV Player Core/M3UInfo.cs b/TV Player Core/M3UInfo.cs new file mode 100644 index 0000000..c46ee17 --- /dev/null +++ b/TV Player Core/M3UInfo.cs @@ -0,0 +1,14 @@ +namespace TV_Player +{ + public class M3UInfo + { + public string CUID { get; set; } + public string Number { get; set; } + public string TvgID { get; set; } + public string TvgName { get; set; } + public string GroupTitle { get; set; } + public string Logo { get; set; } + public string Name{ get; set; } + public string Url { get; set; } + } +} diff --git a/TV Player Core/M3UParser.cs b/TV Player Core/M3UParser.cs new file mode 100644 index 0000000..ad2d4ab --- /dev/null +++ b/TV Player Core/M3UParser.cs @@ -0,0 +1,373 @@ +using System.IO; +using System.IO.Compression; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Policy; +using System.Text; +using System.Text.RegularExpressions; +using System.Xml; + +namespace TV_Player +{ + public class ProgramInfo : ObservableViewModelBase + { + private string _title; + private int _durationValue; + + public string Title { get => _title; set => SetProperty(ref _title, value); } + + public DateTime StartTime { get; set; } + public DateTime EndTime { get; set; } + public int DurationValue { get => _durationValue; set => SetProperty(ref _durationValue, value); } + + } + + public class ProgramGuide + { + public string Id { get; set; } + public string DisplayName { get; set; } + public List Programs { get; set; } = new List(); + } + public static class M3UParser + { + private static string GetWritableAppDataFolder() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrWhiteSpace(localAppData)) + { + return localAppData; + } + + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (!string.IsNullOrWhiteSpace(appData)) + { + return appData; + } + + return AppContext.BaseDirectory; + } + + public static async Task DownloadGuideFromWebAsync(string name, string url) + { + var fileName = name + "_guide.xml"; + string programDataPath = GetWritableAppDataFolder(); + string filePath = Path.Combine(programDataPath, "TVPlayer", fileName); + + + if (File.Exists(filePath)) + { + DateTime creationTime = File.GetCreationTime(filePath); + DateTime modificationTime = File.GetLastWriteTime(filePath); + DateTime currentTime = DateTime.Now; + + if ((currentTime - creationTime).TotalDays < 3 || (currentTime - modificationTime).TotalDays < 3) + { + return; + } + } + ; + var channelsContent = string.Empty; + + if (url.Contains(".gz")) + { + channelsContent = await DownloadGzip(url); + } + else + { + channelsContent = await DownloadXMLProgram(url); + } + Directory.CreateDirectory(Path.GetDirectoryName(filePath)); + await File.WriteAllTextAsync(filePath, channelsContent); + } + + private static async Task DownloadXMLProgram(string url) + { + try + { + using (var client = new HttpClient()) + using (var request = new HttpRequestMessage()) + { + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/text")); + request.Method = HttpMethod.Get; + request.RequestUri = new Uri(url); + var response = await client.GetAsync(url); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + } + catch (HttpRequestException ex) + { + System.Diagnostics.Debug.WriteLine($"Network error downloading XML from {url}: {ex.Message}"); + throw; + } + catch (UriFormatException ex) + { + System.Diagnostics.Debug.WriteLine($"Invalid URL: {url} - {ex.Message}"); + throw; + } + } + + public static async Task DownloadGzip(string url) + { + try + { + using HttpClient httpClient = new HttpClient(); + byte[] compressedData = await httpClient.GetByteArrayAsync(url); + + using MemoryStream compressedStream = new MemoryStream(compressedData); + using GZipStream decompressionStream = new GZipStream(compressedStream, CompressionMode.Decompress); + using MemoryStream decompressedStream = new MemoryStream(); + await decompressionStream.CopyToAsync(decompressedStream); + + string xmlContent = Encoding.UTF8.GetString(decompressedStream.ToArray()); + return xmlContent; + } + catch (HttpRequestException ex) + { + System.Diagnostics.Debug.WriteLine($"Network error downloading gzip from {url}: {ex.Message}"); + return null; + } + catch (InvalidOperationException ex) + { + System.Diagnostics.Debug.WriteLine($"Invalid gzip data: {ex.Message}"); + return null; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error decompressing gzip: {ex.Message}"); + return null; + } + } + + public static async Task ParseEpg(string groupName, string channelId) + { + XmlReaderSettings settings = new XmlReaderSettings(); + settings.IgnoreWhitespace = true; + settings.IgnoreComments = true; + settings.DtdProcessing = DtdProcessing.Parse; + settings.Async = true; + ProgramGuide channel = null; + + var fileName = groupName + "_guide.xml"; + string programDataPath = GetWritableAppDataFolder(); + string filePath = Path.Combine(programDataPath, "TVPlayer", fileName); + + if (!File.Exists(filePath)) + { + System.Diagnostics.Debug.WriteLine($"EPG file not found: {filePath}"); + return null; + } + + using (XmlReader reader = XmlReader.Create(filePath, settings)) + { + try + { + while (await reader.ReadAsync()) + { + if (reader.NodeType == XmlNodeType.Element && reader.Name == "channel") + { + var id = reader.GetAttribute("id"); + if (id == channelId) + { + channel = new ProgramGuide + { + Id = id + }; + reader.Read(); + channel.DisplayName = reader.ReadElementContentAsString(); + } + continue; + } + + if (channel != null && reader.NodeType == XmlNodeType.Element && reader.Name == "programme") + { + ProgramInfo program = new ProgramInfo(); + + var id = reader.GetAttribute("channel"); + if (id != channelId) continue; + + if (!DateTime.TryParseExact(reader.GetAttribute("start"), "yyyyMMddHHmmss zzz", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var startTime)) + continue; + if (!DateTime.TryParseExact(reader.GetAttribute("stop"), "yyyyMMddHHmmss zzz", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var endTime)) + continue; + + program.StartTime = startTime; + program.EndTime = endTime; + + reader.Read(); + program.Title = reader.ReadElementContentAsString(); + + channel.Programs.Add(program); + } + } + } + catch (XmlException ex) + { + System.Diagnostics.Debug.WriteLine($"XML parsing error in EPG file: {ex.Message}"); + } + catch (FileNotFoundException ex) + { + System.Diagnostics.Debug.WriteLine($"EPG file not found: {ex.Message}"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Unexpected error parsing EPG: {ex.Message}"); + } + } + return channel; + } + + private static async Task ReadFile(string url) + { + try + { + using (var client = new HttpClient()) + using (var request = new HttpRequestMessage()) + { + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/text")); + request.Method = HttpMethod.Get; + request.RequestUri = new Uri(url); + var response = await client.GetAsync(url); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + } + catch (HttpRequestException ex) + { + System.Diagnostics.Debug.WriteLine($"Network error reading file from {url}: {ex.Message}"); + throw; + } + catch (UriFormatException ex) + { + System.Diagnostics.Debug.WriteLine($"Invalid URL: {url} - {ex.Message}"); + throw; + } + } + + public static async Task<(List programList, string programGuide)> DownloadM3UFromWebAsync(string url) + { + string fileData; + if (Uri.IsWellFormedUriString(url, UriKind.Absolute)) + { + fileData = await ReadFile(url); + } + else + { + fileData = File.ReadAllText(url); + } + + return ParseM3UFromString(fileData); + } + private static string[] SplitStringBeforeSeparator(string input, string separator) + { + string[] parts = input.Split(separator); + + // Reconstruct the string until the separator is reached + int separatorIndex = input.IndexOf(separator); + if (separatorIndex != -1) + { + parts[0] = input.Substring(0, separatorIndex + 1); + for (int i = 1; i < parts.Length; i++) + { + parts[i] = separator + parts[i]; + } + } + + return parts; + } + + private static (List programList, string programGuide) ParseM3UFromString(string content) + { + List playlistItems = new List(); + string programGuideLink = string.Empty; + try + { + if (string.IsNullOrWhiteSpace(content)) + { + System.Diagnostics.Debug.WriteLine("[M3UParser] M3U content is empty"); + return (playlistItems, programGuideLink); + } + + System.Diagnostics.Debug.WriteLine($"[M3UParser] Starting parse of M3U content ({content.Length} bytes)"); + var m3u = SplitStringBeforeSeparator(content, "#EXT"); + + foreach (var line in m3u) + { + if (line.StartsWith("#EXTINF:")) + { + if (TryParseM3ULine(line, out var m3uInfo)) + { + if (!string.IsNullOrEmpty(m3uInfo?.Url)) + { + playlistItems.Add(m3uInfo); + System.Diagnostics.Debug.WriteLine($"[M3UParser] Parsed: {m3uInfo.Name} -> group='{m3uInfo.GroupTitle}'"); + } + } + } + if (line.StartsWith("#EXTM3U")) + { + programGuideLink = ExtractXtvgUrl(line); + } + } + + var groupSummary = playlistItems.GroupBy(p => p.GroupTitle).Select(g => $"{g.Key}({g.Count()})").ToList(); + System.Diagnostics.Debug.WriteLine($"[M3UParser] Parse complete: {playlistItems.Count} programs in {groupSummary.Count} groups: {string.Join(", ", groupSummary)}"); + } + catch (ArgumentException ex) + { + System.Diagnostics.Debug.WriteLine($"Invalid M3U format: {ex.Message}"); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"Error parsing M3U file: {ex.Message}"); + } + + return (playlistItems, programGuideLink); + } + + private static bool TryParseM3ULine(string m3uLine, out M3UInfo? info) + { + info = null; + string pattern = @"#EXTINF:(-?\d+)\s*?(?:timeshift=""(?.*?)""\s+)?(?:catchup-days=""(?.*?)""\s+)?(?:catchup-type=""(?.*?)""\s+)?(?:CUID=""(?.*?)""\s+)?(?:number=""(?.*?)""\s+)?(?:tvg-id=""(?.*?)""\s+)?(?:tvg-name=""(?.*?)""\s+)?(?:group-title=""(?.*?)""\s+)?(?:tvg-logo=""(?.*?)""\s*)?(?:group-title=""(?.*?)"")?(?:,(?.*?)\s*\r?\n(?.*))"; + //string pattern = @"#EXTINF:(-?\d+)\s+?(?:timeshift=""(?.*?)""\s+)?(?:catchup-days=""(?.*?)""\s+)?(?:catchup-type=""(?.*?)""\s+)?(?:CUID=""(?.*?)""\s+)?(?:number=""(?.*?)""\s+)?(?:tvg-id=""(?.*?)""\s+)?(?:tvg-name=""(?.*?)""\s+)?(?:group-title=""(?.*?)""\s+)?(?:tvg-logo=""(?.*?)"")?,(?.*?)\s*\r?\n(?.*)"; + Regex regex = new Regex(pattern, RegexOptions.IgnoreCase); + + Match match = regex.Match(m3uLine); + if (match.Success) + { + info = new M3UInfo + { + CUID = match.Groups["CUID"].Value, + Number = match.Groups["Number"].Value, + TvgID = match.Groups["TvgID"].Value, + TvgName = match.Groups["TvgName"].Value, + GroupTitle = string.IsNullOrEmpty(match.Groups["GroupTitle"].Value) ? "undefined" : match.Groups["GroupTitle"].Value, + Logo = match.Groups["Logo"].Value, + Name = match.Groups["Name"].Value, + Url = match.Groups["URL"].Value + }; + return true; + } + + return false; + } + + private static string ExtractXtvgUrl(string m3uEntry) + { + // Define a regular expression pattern to match x-tvg-url attribute + string pattern = @"(x-tvg-url=""(.*?)"")?(url-tvg=""(.*?)"")"; + + // Use Regex.Match to find the first match + Match match = Regex.Match(m3uEntry, pattern); + + // Check if a match is found and get the value from the capturing group + if (match.Success && match.Groups.Count > 1) + { + return match.Groups[4].Value; + } + + // Return null or an empty string if no match is found + return string.Empty; + } + } +} diff --git a/TV Player Core/ObservableViewModelBase.cs b/TV Player Core/ObservableViewModelBase.cs new file mode 100644 index 0000000..f54f263 --- /dev/null +++ b/TV Player Core/ObservableViewModelBase.cs @@ -0,0 +1,28 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace TV_Player +{ + public abstract class ObservableViewModelBase : INotifyPropertyChanged + { + public event PropertyChangedEventHandler PropertyChanged; + + protected void RaisePropertyChanged(string propertyName) + => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + /// + /// Set a property and raise a property changed event if it has changed + /// + protected bool SetProperty(ref T property, T value, [CallerMemberName] string propertyName = null) + { + if (EqualityComparer.Default.Equals(property, value)) + { + return false; + } + + property = value; + RaisePropertyChanged(propertyName); + return true; + } + } +} diff --git a/TV Player Core/ProgramsData.cs b/TV Player Core/ProgramsData.cs new file mode 100644 index 0000000..b4c598a --- /dev/null +++ b/TV Player Core/ProgramsData.cs @@ -0,0 +1,66 @@ +using System.Reactive; +using System.Reactive.Subjects; + +namespace TV_Player +{ + public class ProgramsData + { + private readonly ReplaySubject> programsSubject = new ReplaySubject>(); + private readonly ReplaySubject> groupsSubject = new ReplaySubject>(); + private readonly ReplaySubject programGuideSubject = new ReplaySubject(); + public IObservable> AllPrograms => programsSubject; + public IObservable> GroupsInformation => groupsSubject; + public IObservable ProgramGuideInfo => programGuideSubject; + + private readonly string _programName; + public ProgramsData(string name,string playlistURL) + { + _programName = name; + Task.Run(() => GetPrograms(name,playlistURL)); + } + + private async Task GetPrograms(string name,string m3uLink) + { + System.Diagnostics.Debug.WriteLine($"[ProgramsData] Starting download of: {m3uLink}"); + //string m3uLink = "http://pl.da-tv.vip/a71e77fa/835b3216/tv.m3u"; + try + { + var result = await M3UParser.DownloadM3UFromWebAsync(m3uLink); + System.Diagnostics.Debug.WriteLine($"[ProgramsData] Downloaded {result.programList.Count} programs"); + + programsSubject.OnNext(result.programList); + + var groupping = result.programList.GroupBy(item => item.GroupTitle) + .Select(group => new GroupInfo() { Name = group.Key, Count = group.Count() }) + .OrderBy(g => g.Name) + .ToList(); + System.Diagnostics.Debug.WriteLine($"[ProgramsData] Publishing {groupping.Count} groups: {string.Join(", ", groupping.Select(g => $"{g.Name}({g.Count})"))}"); + if (groupping.Count == 0) + { + System.Diagnostics.Debug.WriteLine("[ProgramsData] WARNING: No groups found! Check if programs have 'group-title' metadata."); + } + groupsSubject.OnNext(groupping); + + await Task.Run(() => GetProgramGuide(name, result.programGuide)); + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"[ProgramsData] ERROR downloading programs: {ex.Message}"); + throw; + } + } + + public Task GetGuideByProgram(string channelID) + { + return M3UParser.ParseEpg(_programName,channelID); + } + + private async Task GetProgramGuide(string name, string guideLink) + { + //guideLink = "http://epg.da-tv.vip/107-light.xml"; + await M3UParser.DownloadGuideFromWebAsync(name,guideLink); + programGuideSubject.OnNext(Unit.Default); + } + + } +} diff --git a/TV Player Core/SettingsModel.cs b/TV Player Core/SettingsModel.cs new file mode 100644 index 0000000..079e791 --- /dev/null +++ b/TV Player Core/SettingsModel.cs @@ -0,0 +1,87 @@ +using Newtonsoft.Json; +using System.IO; + +namespace TV_Player.ViewModels +{ + public static class SettingsModel + { + private static readonly string AppDataFolder = Path.Combine(GetWritableAppDataFolder(), "TVPlayer"); + private static readonly string SettingsFilePath = Path.Combine(AppDataFolder, "settings.json"); + + private static string GetWritableAppDataFolder() + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrWhiteSpace(localAppData)) + { + return localAppData; + } + + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (!string.IsNullOrWhiteSpace(appData)) + { + return appData; + } + + return AppContext.BaseDirectory; + } + + public static Dictionary Playlists { get; set; } + public static bool StartFullScreen { get; set; } + public static bool StartFromLastScreen { get; set; } + public static string LastScreen { get; set; } + public static GroupInfo Group { get; set; } + public static M3UInfo Program { get; set; } + public static string[] HiddenGroups { get; set; } + + public static void SaveSetttings() + { + // Create an anonymous object to hold the properties + var dataToSerialize = new + { + Playlists, + StartFromLastScreen, + StartFullScreen, + LastScreen, + Group, + Program, + HiddenGroups, + }; + + // Serialize the object to JSON + string json = JsonConvert.SerializeObject(dataToSerialize, Formatting.Indented); + + if (!Directory.Exists(AppDataFolder)) + Directory.CreateDirectory(AppDataFolder); + // Save the JSON to a file + File.WriteAllText(SettingsFilePath, json); + } + + public static void LoadSettings() + { + var loadedData = new + { + Playlists = default(Dictionary), + LastScreen = default(string), + Group = default(GroupInfo), + Program = default(M3UInfo), + StartFromLastScreen = default(bool), + StartFullScreen = default(bool), + HiddenGroups = default(string[]) + }; + if (File.Exists(SettingsFilePath)) + { + // Read the JSON content from the file + string json = File.ReadAllText(SettingsFilePath); + loadedData = JsonConvert.DeserializeAnonymousType(json, loadedData); + } + // Assign the values to the properties + Playlists = loadedData.Playlists; + LastScreen = loadedData.LastScreen; + Group = loadedData.Group; + Program = loadedData.Program; + StartFromLastScreen = loadedData.StartFromLastScreen; + StartFullScreen = loadedData.StartFullScreen; + HiddenGroups = loadedData.HiddenGroups; + } + } +} diff --git a/TV Player Core/TV Player Core.csproj b/TV Player Core/TV Player Core.csproj new file mode 100644 index 0000000..d88cec8 --- /dev/null +++ b/TV Player Core/TV Player Core.csproj @@ -0,0 +1,17 @@ + + + + Library + net8.0 + TV_Player + TV Player Core + enable + enable + + + + + + + + diff --git a/TV Player WPF/TV Player WPF.csproj b/TV Player WPF/TV Player WPF.csproj index b0fac19..453ffbd 100644 --- a/TV Player WPF/TV Player WPF.csproj +++ b/TV Player WPF/TV Player WPF.csproj @@ -30,6 +30,19 @@ + + + + + + + + + + + + + Never diff --git a/TV player.sln b/TV player.sln index 73f5afc..b73b8ed 100644 --- a/TV player.sln +++ b/TV player.sln @@ -9,6 +9,8 @@ Project("{54435603-DBB4-11D2-8724-00A0C9A8B90C}") = "TVPlayerSetup", "TVPlayerSe EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TV Player Avalonia", "TV Player Avalonia\TV Player Avalonia.csproj", "{CDF6DAD3-4880-4E03-8EE3-7B6568442BFC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TV Player Core", "TV Player Core\TV Player Core.csproj", "{AA32E630-977F-4494-84A6-5A7245C9F892}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -31,6 +33,10 @@ Global {CDF6DAD3-4880-4E03-8EE3-7B6568442BFC}.Debug|Any CPU.Build.0 = Debug|Any CPU {CDF6DAD3-4880-4E03-8EE3-7B6568442BFC}.Release|Any CPU.ActiveCfg = Release|Any CPU {CDF6DAD3-4880-4E03-8EE3-7B6568442BFC}.Release|Any CPU.Build.0 = Release|Any CPU + {AA32E630-977F-4494-84A6-5A7245C9F892}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA32E630-977F-4494-84A6-5A7245C9F892}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA32E630-977F-4494-84A6-5A7245C9F892}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA32E630-977F-4494-84A6-5A7245C9F892}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE