feat: Refactor project structure and add core library

- Migrate shared components from WPF to a new Core library.
- Introduce GroupInfo, M3UInfo, and ObservableViewModelBase classes.
- Implement M3UParser for downloading and parsing M3U files.
- Add ProgramsData for managing program lists and guides.
- Create SettingsModel for application settings management.
- Update project references in solution files.
This commit is contained in:
Vladimir
2026-03-22 13:11:32 +02:00
parent 1e8e444376
commit 689450393b
10 changed files with 614 additions and 6 deletions
+1 -6
View File
@@ -23,11 +23,6 @@
</ItemGroup>
<ItemGroup>
<Compile Include="..\TV Player WPF\PlaylistWorker\GroupInfo.cs" Link="Shared\GroupInfo.cs" />
<Compile Include="..\TV Player WPF\PlaylistWorker\M3UInfo.cs" Link="Shared\M3UInfo.cs" />
<Compile Include="..\TV Player WPF\PlaylistWorker\M3UParser.cs" Link="Shared\M3UParser.cs" />
<Compile Include="..\TV Player WPF\ViewModels\ObservableViewModelBase.cs" Link="Shared\ObservableViewModelBase.cs" />
<Compile Include="..\TV Player WPF\ViewModels\ProgramsData.cs" Link="Shared\ProgramsData.cs" />
<Compile Include="..\TV Player WPF\ViewModels\SettingsModel.cs" Link="Shared\SettingsModel.cs" />
<ProjectReference Include="..\TV Player Core\TV Player Core.csproj" />
</ItemGroup>
</Project>
+9
View File
@@ -0,0 +1,9 @@
namespace TV_Player
{
[Serializable]
public class GroupInfo
{
public string Name { get; set; }
public int Count { get; set; }
}
}
+14
View File
@@ -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; }
}
}
+373
View File
@@ -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<ProgramInfo> Programs { get; set; } = new List<ProgramInfo>();
}
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<string> 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<string> 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<ProgramGuide> 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<string> 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<M3UInfo> 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<M3UInfo> programList, string programGuide) ParseM3UFromString(string content)
{
List<M3UInfo> playlistItems = new List<M3UInfo>();
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=""(?<Timeshift>.*?)""\s+)?(?:catchup-days=""(?<CatchupDays>.*?)""\s+)?(?:catchup-type=""(?<CatchupType>.*?)""\s+)?(?:CUID=""(?<CUID>.*?)""\s+)?(?:number=""(?<Number>.*?)""\s+)?(?:tvg-id=""(?<TvgID>.*?)""\s+)?(?:tvg-name=""(?<TvgName>.*?)""\s+)?(?:group-title=""(?<GroupTitle>.*?)""\s+)?(?:tvg-logo=""(?<Logo>.*?)""\s*)?(?:group-title=""(?<GroupTitle>.*?)"")?(?:,(?<Name>.*?)\s*\r?\n(?<URL>.*))";
//string pattern = @"#EXTINF:(-?\d+)\s+?(?:timeshift=""(?<Timeshift>.*?)""\s+)?(?:catchup-days=""(?<CatchupDays>.*?)""\s+)?(?:catchup-type=""(?<CatchupType>.*?)""\s+)?(?:CUID=""(?<CUID>.*?)""\s+)?(?:number=""(?<Number>.*?)""\s+)?(?:tvg-id=""(?<TvgID>.*?)""\s+)?(?:tvg-name=""(?<TvgName>.*?)""\s+)?(?:group-title=""(?<GroupTitle>.*?)""\s+)?(?:tvg-logo=""(?<Logo>.*?)"")?,(?<Name>.*?)\s*\r?\n(?<URL>.*)";
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;
}
}
}
+28
View File
@@ -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));
/// <summary>
/// Set a property and raise a property changed event if it has changed
/// </summary>
protected bool SetProperty<T>(ref T property, T value, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(property, value))
{
return false;
}
property = value;
RaisePropertyChanged(propertyName);
return true;
}
}
}
+66
View File
@@ -0,0 +1,66 @@
using System.Reactive;
using System.Reactive.Subjects;
namespace TV_Player
{
public class ProgramsData
{
private readonly ReplaySubject<List<M3UInfo>> programsSubject = new ReplaySubject<List<M3UInfo>>();
private readonly ReplaySubject<List<GroupInfo>> groupsSubject = new ReplaySubject<List<GroupInfo>>();
private readonly ReplaySubject<Unit> programGuideSubject = new ReplaySubject<Unit>();
public IObservable<List<M3UInfo>> AllPrograms => programsSubject;
public IObservable<List<GroupInfo>> GroupsInformation => groupsSubject;
public IObservable<Unit> 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<ProgramGuide> 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);
}
}
}
+87
View File
@@ -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<string,string> 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<string,string>),
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;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>TV_Player</RootNamespace>
<AssemblyName>TV Player Core</AssemblyName>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="System.Reactive" Version="6.0.2" />
</ItemGroup>
</Project>
+13
View File
@@ -30,6 +30,19 @@
<PackageReference Include="System.Reactive" Version="6.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TV Player Core\TV Player Core.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Remove="PlaylistWorker\GroupInfo.cs" />
<Compile Remove="PlaylistWorker\M3UInfo.cs" />
<Compile Remove="PlaylistWorker\M3UParser.cs" />
<Compile Remove="ViewModels\ObservableViewModelBase.cs" />
<Compile Remove="ViewModels\ProgramsData.cs" />
<Compile Remove="ViewModels\SettingsModel.cs" />
</ItemGroup>
<ItemGroup>
<Resource Include="Assets\bkground.jpg">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
+6
View File
@@ -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