diff --git a/.gitignore b/.gitignore index de2f7d4..e522683 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,9 @@ app/debug/ dashboard/node_modules/ dashboard/server.log dashboard/config.json + +# Windows desktop project artifacts +windows/StreamPlayer.Desktop/.vs/ +windows/StreamPlayer.Desktop/bin/ +windows/StreamPlayer.Desktop/obj/ +windows/StreamPlayer.Desktop/ResolverTest/ diff --git a/windows/StreamPlayer.Desktop/App.axaml b/windows/StreamPlayer.Desktop/App.axaml new file mode 100644 index 0000000..9ffa5d7 --- /dev/null +++ b/windows/StreamPlayer.Desktop/App.axaml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/windows/StreamPlayer.Desktop/App.axaml.cs b/windows/StreamPlayer.Desktop/App.axaml.cs new file mode 100644 index 0000000..bac4483 --- /dev/null +++ b/windows/StreamPlayer.Desktop/App.axaml.cs @@ -0,0 +1,49 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core; +using Avalonia.Data.Core.Plugins; +using System.Linq; +using Avalonia.Markup.Xaml; +using LibVLCSharp.Shared; +using StreamPlayer.Desktop.ViewModels; +using StreamPlayer.Desktop.Views; + +namespace StreamPlayer.Desktop; + +public partial class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + Core.Initialize(); + // Avoid duplicate validations from both Avalonia and the CommunityToolkit. + // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins + DisableAvaloniaDataAnnotationValidation(); + desktop.MainWindow = new MainWindow + { + DataContext = new MainWindowViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } + + private void DisableAvaloniaDataAnnotationValidation() + { + // Get an array of plugins to remove + var dataValidationPluginsToRemove = + BindingPlugins.DataValidators.OfType().ToArray(); + + // remove each entry found + foreach (var plugin in dataValidationPluginsToRemove) + { + BindingPlugins.DataValidators.Remove(plugin); + } + } +} diff --git a/windows/StreamPlayer.Desktop/AppVersion.cs b/windows/StreamPlayer.Desktop/AppVersion.cs new file mode 100644 index 0000000..3900cd6 --- /dev/null +++ b/windows/StreamPlayer.Desktop/AppVersion.cs @@ -0,0 +1,14 @@ +namespace StreamPlayer.Desktop; + +/// +/// Centraliza los metadatos de versión y endpoints compartidos con la app Android original. +/// +public static class AppVersion +{ + public const string VersionName = "9.4.6"; + public const int VersionCode = 94600; + + public const string DeviceRegistryUrl = "http://194.163.191.200:4000"; + public const string LatestReleaseApi = + "https://gitea.cbcren.online/api/v1/repos/renato97/app/releases/latest"; +} diff --git a/windows/StreamPlayer.Desktop/Assets/avalonia-logo.ico b/windows/StreamPlayer.Desktop/Assets/avalonia-logo.ico new file mode 100644 index 0000000..f7da8bb Binary files /dev/null and b/windows/StreamPlayer.Desktop/Assets/avalonia-logo.ico differ diff --git a/windows/StreamPlayer.Desktop/Converters/BooleanToBrushConverter.cs b/windows/StreamPlayer.Desktop/Converters/BooleanToBrushConverter.cs new file mode 100644 index 0000000..bbe82bb --- /dev/null +++ b/windows/StreamPlayer.Desktop/Converters/BooleanToBrushConverter.cs @@ -0,0 +1,23 @@ +using System; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace StreamPlayer.Desktop.Converters; + +public sealed class BooleanToBrushConverter : IValueConverter +{ + public IBrush TrueBrush { get; set; } = Brushes.White; + public IBrush FalseBrush { get; set; } = Brushes.Gray; + + public object? Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + { + if (value is bool boolean) + { + return boolean ? TrueBrush : FalseBrush; + } + return Avalonia.AvaloniaProperty.UnsetValue; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) => + Avalonia.AvaloniaProperty.UnsetValue; +} diff --git a/windows/StreamPlayer.Desktop/Converters/InverseBooleanConverter.cs b/windows/StreamPlayer.Desktop/Converters/InverseBooleanConverter.cs new file mode 100644 index 0000000..63c9fed --- /dev/null +++ b/windows/StreamPlayer.Desktop/Converters/InverseBooleanConverter.cs @@ -0,0 +1,27 @@ +using System; +using Avalonia.Data.Converters; + +namespace StreamPlayer.Desktop.Converters; + +public sealed class InverseBooleanConverter : IValueConverter +{ + public static readonly InverseBooleanConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + { + if (value is bool boolean) + { + return !boolean; + } + return Avalonia.AvaloniaProperty.UnsetValue; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture) + { + if (value is bool boolean) + { + return !boolean; + } + return Avalonia.AvaloniaProperty.UnsetValue; + } +} diff --git a/windows/StreamPlayer.Desktop/JsonExtensions.cs b/windows/StreamPlayer.Desktop/JsonExtensions.cs new file mode 100644 index 0000000..ca6bee0 --- /dev/null +++ b/windows/StreamPlayer.Desktop/JsonExtensions.cs @@ -0,0 +1,63 @@ +using System.Text.Json; + +namespace StreamPlayer.Desktop; + +public static class JsonExtensions +{ + public static string GetPropertyOrDefault(this JsonElement element, string propertyName) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property)) + { + return property.ValueKind switch + { + JsonValueKind.String => property.GetString() ?? string.Empty, + JsonValueKind.Number => property.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + _ => property.GetRawText() + }; + } + + return string.Empty; + } + + public static int GetPropertyOrDefaultInt(this JsonElement element, string propertyName, int fallback = 0) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property)) + { + if (property.TryGetInt32(out var value)) + { + return value; + } + } + return fallback; + } + + public static long GetPropertyOrDefaultLong(this JsonElement element, string propertyName, long fallback = 0) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property)) + { + if (property.TryGetInt64(out var value)) + { + return value; + } + } + return fallback; + } + + public static bool GetPropertyOrDefaultBool(this JsonElement element, string propertyName, bool fallback = false) + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(propertyName, out var property)) + { + if (property.ValueKind == JsonValueKind.True) + { + return true; + } + if (property.ValueKind == JsonValueKind.False) + { + return false; + } + } + return fallback; + } +} diff --git a/windows/StreamPlayer.Desktop/Models/ChannelSection.cs b/windows/StreamPlayer.Desktop/Models/ChannelSection.cs new file mode 100644 index 0000000..cb416d0 --- /dev/null +++ b/windows/StreamPlayer.Desktop/Models/ChannelSection.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace StreamPlayer.Desktop.Models; + +public enum SectionType +{ + Events, + Channels +} + +public sealed record ChannelSection(string Title, SectionType Type, IReadOnlyList Channels) +{ + public bool IsEvents => Type == SectionType.Events; +} diff --git a/windows/StreamPlayer.Desktop/Models/DeviceStatus.cs b/windows/StreamPlayer.Desktop/Models/DeviceStatus.cs new file mode 100644 index 0000000..09dc83e --- /dev/null +++ b/windows/StreamPlayer.Desktop/Models/DeviceStatus.cs @@ -0,0 +1,6 @@ +namespace StreamPlayer.Desktop.Models; + +public sealed record DeviceStatus(bool IsBlocked, string Reason, string TokenPart) +{ + public static DeviceStatus Allowed() => new(false, string.Empty, string.Empty); +} diff --git a/windows/StreamPlayer.Desktop/Models/LiveEvent.cs b/windows/StreamPlayer.Desktop/Models/LiveEvent.cs new file mode 100644 index 0000000..2f1322a --- /dev/null +++ b/windows/StreamPlayer.Desktop/Models/LiveEvent.cs @@ -0,0 +1,37 @@ +using System; + +namespace StreamPlayer.Desktop.Models; + +public sealed record LiveEvent( + string Title, + string DisplayTime, + string Category, + string Status, + string PageUrl, + string ChannelName, + long StartTimestamp) +{ + public bool IsLive => + !string.IsNullOrWhiteSpace(Status) && + Status.Contains("live", StringComparison.OrdinalIgnoreCase); + + public string Subtitle + { + get + { + if (string.IsNullOrWhiteSpace(DisplayTime) && string.IsNullOrWhiteSpace(Category)) + { + return string.Empty; + } + if (string.IsNullOrWhiteSpace(Category)) + { + return DisplayTime; + } + if (string.IsNullOrWhiteSpace(DisplayTime)) + { + return Category; + } + return $"{DisplayTime} · {Category}"; + } + } +} diff --git a/windows/StreamPlayer.Desktop/Models/StreamChannel.cs b/windows/StreamPlayer.Desktop/Models/StreamChannel.cs new file mode 100644 index 0000000..02c93e6 --- /dev/null +++ b/windows/StreamPlayer.Desktop/Models/StreamChannel.cs @@ -0,0 +1,3 @@ +namespace StreamPlayer.Desktop.Models; + +public sealed record StreamChannel(string Name, string PageUrl); diff --git a/windows/StreamPlayer.Desktop/Models/UpdateInfo.cs b/windows/StreamPlayer.Desktop/Models/UpdateInfo.cs new file mode 100644 index 0000000..7cf6ad7 --- /dev/null +++ b/windows/StreamPlayer.Desktop/Models/UpdateInfo.cs @@ -0,0 +1,52 @@ +using System; +using System.Globalization; + +namespace StreamPlayer.Desktop.Models; + +public sealed record UpdateInfo( + int VersionCode, + string VersionName, + string ReleaseNotes, + string DownloadUrl, + string DownloadFileName, + long DownloadSizeBytes, + int DownloadCount, + string ReleasePageUrl, + int MinSupportedVersionCode, + bool ForceUpdate) +{ + public bool IsUpdateAvailable(int currentVersionCode) => VersionCode > currentVersionCode; + + public bool IsMandatory(int currentVersionCode) => + ForceUpdate || (MinSupportedVersionCode > 0 && currentVersionCode < MinSupportedVersionCode); + + public string GetReleaseNotesPreview(int maxLength = 900) + { + if (string.IsNullOrWhiteSpace(ReleaseNotes)) + { + return string.Empty; + } + if (ReleaseNotes.Length <= maxLength) + { + return ReleaseNotes.Trim(); + } + return ReleaseNotes[..maxLength].TrimEnd() + Environment.NewLine + "…"; + } + + public string FormatSize() + { + if (DownloadSizeBytes <= 0) + { + return string.Empty; + } + string[] suffixes = { "B", "KB", "MB", "GB" }; + double size = DownloadSizeBytes; + int index = 0; + while (size >= 1024 && index < suffixes.Length - 1) + { + size /= 1024; + index++; + } + return $"{size:0.##} {suffixes[index]}"; + } +} diff --git a/windows/StreamPlayer.Desktop/Program.cs b/windows/StreamPlayer.Desktop/Program.cs new file mode 100644 index 0000000..b38e3a3 --- /dev/null +++ b/windows/StreamPlayer.Desktop/Program.cs @@ -0,0 +1,21 @@ +using Avalonia; +using System; + +namespace StreamPlayer.Desktop; + +sealed class Program +{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} diff --git a/windows/StreamPlayer.Desktop/Services/ChannelRepository.cs b/windows/StreamPlayer.Desktop/Services/ChannelRepository.cs new file mode 100644 index 0000000..3f7808f --- /dev/null +++ b/windows/StreamPlayer.Desktop/Services/ChannelRepository.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StreamPlayer.Desktop.Models; + +namespace StreamPlayer.Desktop.Services; + +public static class ChannelRepository +{ + private static readonly IReadOnlyList Channels = BuildChannels(); + + public static IReadOnlyList GetChannels() => Channels; + + private static IReadOnlyList BuildChannels() + { + var list = new List + { + new("ESPN", "https://streamtpmedia.com/global2.php?stream=espn"), + new("ESPN 2", "https://streamtpmedia.com/global2.php?stream=espn2"), + new("ESPN 3", "https://streamtpmedia.com/global2.php?stream=espn3"), + new("ESPN 4", "https://streamtpmedia.com/global2.php?stream=espn4"), + new("ESPN 3 MX", "https://streamtpmedia.com/global2.php?stream=espn3mx"), + new("ESPN 5", "https://streamtpmedia.com/global2.php?stream=espn5"), + new("Fox Sports 3 MX", "https://streamtpmedia.com/global2.php?stream=foxsports3mx"), + new("ESPN 6", "https://streamtpmedia.com/global2.php?stream=espn6"), + new("Fox Sports MX", "https://streamtpmedia.com/global2.php?stream=foxsportsmx"), + new("ESPN 7", "https://streamtpmedia.com/global2.php?stream=espn7"), + new("Azteca Deportes", "https://streamtpmedia.com/global2.php?stream=azteca_deportes"), + new("Win Plus", "https://streamtpmedia.com/global2.php?stream=winplus"), + new("DAZN 1", "https://streamtpmedia.com/global2.php?stream=dazn1"), + new("Win Plus 2", "https://streamtpmedia.com/global2.php?stream=winplus2"), + new("DAZN 2", "https://streamtpmedia.com/global2.php?stream=dazn2"), + new("Win Sports", "https://streamtpmedia.com/global2.php?stream=winsports"), + new("DAZN LaLiga", "https://streamtpmedia.com/global2.php?stream=dazn_laliga"), + new("Win Plus Online 1", "https://streamtpmedia.com/global2.php?stream=winplusonline1"), + new("Caracol TV", "https://streamtpmedia.com/global2.php?stream=caracoltv"), + new("Fox 1 AR", "https://streamtpmedia.com/global2.php?stream=fox1ar"), + new("Fox 2 USA", "https://streamtpmedia.com/global2.php?stream=fox_2_usa"), + new("Fox 2 AR", "https://streamtpmedia.com/global2.php?stream=fox2ar"), + new("TNT 1 GB", "https://streamtpmedia.com/global2.php?stream=tnt_1_gb"), + new("TNT 2 GB", "https://streamtpmedia.com/global2.php?stream=tnt_2_gb"), + new("Fox 3 AR", "https://streamtpmedia.com/global2.php?stream=fox3ar"), + new("Universo USA", "https://streamtpmedia.com/global2.php?stream=universo_usa"), + new("DSports", "https://streamtpmedia.com/global2.php?stream=dsports"), + new("Univision USA", "https://streamtpmedia.com/global2.php?stream=univision_usa"), + new("DSports 2", "https://streamtpmedia.com/global2.php?stream=dsports2"), + new("Fox Deportes USA", "https://streamtpmedia.com/global2.php?stream=fox_deportes_usa"), + new("DSports Plus", "https://streamtpmedia.com/global2.php?stream=dsportsplus"), + new("Fox Sports 2 MX", "https://streamtpmedia.com/global2.php?stream=foxsports2mx"), + new("TNT Sports Chile", "https://streamtpmedia.com/global2.php?stream=tntsportschile"), + new("Fox Sports Premium", "https://streamtpmedia.com/global2.php?stream=foxsportspremium"), + new("TNT Sports", "https://streamtpmedia.com/global2.php?stream=tntsports"), + new("ESPN MX", "https://streamtpmedia.com/global2.php?stream=espnmx"), + new("ESPN Premium", "https://streamtpmedia.com/global2.php?stream=espnpremium"), + new("ESPN 2 MX", "https://streamtpmedia.com/global2.php?stream=espn2mx"), + new("TyC Sports", "https://streamtpmedia.com/global2.php?stream=tycsports"), + new("TUDN USA", "https://streamtpmedia.com/global2.php?stream=tudn_usa"), + new("Telefe", "https://streamtpmedia.com/global2.php?stream=telefe"), + new("TNT 3 GB", "https://streamtpmedia.com/global2.php?stream=tnt_3_gb"), + new("TV Pública", "https://streamtpmedia.com/global2.php?stream=tv_publica"), + new("Fox 1 USA", "https://streamtpmedia.com/global2.php?stream=fox_1_usa"), + new("Liga 1 Max", "https://streamtpmedia.com/global2.php?stream=liga1max"), + new("Gol TV", "https://streamtpmedia.com/global2.php?stream=goltv"), + new("VTV Plus", "https://streamtpmedia.com/global2.php?stream=vtvplus"), + new("ESPN Deportes", "https://streamtpmedia.com/global2.php?stream=espndeportes"), + new("Gol Perú", "https://streamtpmedia.com/global2.php?stream=golperu"), + new("TNT 4 GB", "https://streamtpmedia.com/global2.php?stream=tnt_4_gb"), + new("SportTV BR 1", "https://streamtpmedia.com/global2.php?stream=sporttvbr1"), + new("SportTV BR 2", "https://streamtpmedia.com/global2.php?stream=sporttvbr2"), + new("SportTV BR 3", "https://streamtpmedia.com/global2.php?stream=sporttvbr3"), + new("Premiere 1", "https://streamtpmedia.com/global2.php?stream=premiere1"), + new("Premiere 2", "https://streamtpmedia.com/global2.php?stream=premiere2"), + new("Premiere 3", "https://streamtpmedia.com/global2.php?stream=premiere3"), + new("ESPN NL 1", "https://streamtpmedia.com/global2.php?stream=espn_nl1"), + new("ESPN NL 2", "https://streamtpmedia.com/global2.php?stream=espn_nl2"), + new("ESPN NL 3", "https://streamtpmedia.com/global2.php?stream=espn_nl3"), + new("Caliente TV MX", "https://streamtpmedia.com/global2.php?stream=calientetvmx"), + new("USA Network", "https://streamtpmedia.com/global2.php?stream=usa_network"), + new("TyC Internacional", "https://streamtpmedia.com/global2.php?stream=tycinternacional"), + new("Canal 5 MX", "https://streamtpmedia.com/global2.php?stream=canal5mx"), + new("TUDN MX", "https://streamtpmedia.com/global2.php?stream=TUDNMX"), + new("FUTV", "https://streamtpmedia.com/global2.php?stream=futv"), + new("LaLiga Hypermotion", "https://streamtpmedia.com/global2.php?stream=laligahypermotion") + }; + + return list + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } +} diff --git a/windows/StreamPlayer.Desktop/Services/DeviceRegistryService.cs b/windows/StreamPlayer.Desktop/Services/DeviceRegistryService.cs new file mode 100644 index 0000000..781813b --- /dev/null +++ b/windows/StreamPlayer.Desktop/Services/DeviceRegistryService.cs @@ -0,0 +1,91 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using StreamPlayer.Desktop.Models; + +namespace StreamPlayer.Desktop.Services; + +public sealed class DeviceRegistryService +{ + private static readonly HttpClient HttpClient = new(new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.All + }) + { + Timeout = TimeSpan.FromSeconds(20) + }; + + private readonly string _deviceId = CreateDeviceId(); + + public async Task SyncAsync(CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(AppVersion.DeviceRegistryUrl)) + { + return DeviceStatus.Allowed(); + } + + var payload = new + { + deviceId = _deviceId, + deviceName = Environment.MachineName, + model = RuntimeInformation.OSDescription, + manufacturer = "Microsoft", + osVersion = Environment.OSVersion.VersionString, + appVersionName = AppVersion.VersionName, + appVersionCode = AppVersion.VersionCode + }; + + string endpoint = $"{SanitizeBaseUrl(AppVersion.DeviceRegistryUrl)}/api/devices/register"; + var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + using var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = content + }; + using var response = await HttpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + string body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + + bool blocked = root.GetPropertyOrDefaultBool("blocked", false); + string reason = root.GetPropertyOrDefault("message"); + if (root.TryGetProperty("device", out var deviceElement)) + { + if (string.IsNullOrWhiteSpace(reason)) + { + reason = deviceElement.GetPropertyOrDefault("notes"); + } + } + string tokenPart = string.Empty; + if (root.TryGetProperty("verification", out var verificationElement)) + { + bool verificationRequired = verificationElement.GetPropertyOrDefaultBool("required", false); + blocked = blocked || verificationRequired; + tokenPart = verificationElement.GetPropertyOrDefault("clientTokenPart"); + } + + return new DeviceStatus(blocked, reason, tokenPart); + } + + private static string SanitizeBaseUrl(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + return value.EndsWith("/", StringComparison.Ordinal) ? value.TrimEnd('/') : value; + } + + private static string CreateDeviceId() + { + string raw = $"{Environment.MachineName}|{Environment.UserName}|{RuntimeInformation.OSDescription}"; + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(raw)); + return Convert.ToHexString(hash)[..24].ToLowerInvariant(); + } +} diff --git a/windows/StreamPlayer.Desktop/Services/DnsHelper.cs b/windows/StreamPlayer.Desktop/Services/DnsHelper.cs new file mode 100644 index 0000000..b3ac569 --- /dev/null +++ b/windows/StreamPlayer.Desktop/Services/DnsHelper.cs @@ -0,0 +1,34 @@ +using System; +using System.Net; +using System.Threading.Tasks; + +namespace StreamPlayer.Desktop.Services; + +public static class DnsHelper +{ + private static readonly string[] DomainsToPrefetch = + { + "streamtpmedia.com", + "google.com", + "doubleclick.net" + }; + + public static void WarmUp() + { + ServicePointManager.DnsRefreshTimeout = (int)TimeSpan.FromMinutes(5).TotalMilliseconds; + _ = Task.Run(async () => + { + foreach (var domain in DomainsToPrefetch) + { + try + { + await Dns.GetHostAddressesAsync(domain).ConfigureAwait(false); + } + catch + { + // Ignore individual failures, this is best-effort caching. + } + } + }); + } +} diff --git a/windows/StreamPlayer.Desktop/Services/EventService.cs b/windows/StreamPlayer.Desktop/Services/EventService.cs new file mode 100644 index 0000000..bcb07f8 --- /dev/null +++ b/windows/StreamPlayer.Desktop/Services/EventService.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using StreamPlayer.Desktop.Models; + +namespace StreamPlayer.Desktop.Services; + +public sealed class EventService +{ + private static readonly Uri EventsUri = new("https://streamtpmedia.com/eventos.json"); + private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(24); + private static readonly string CachePath = Path.Combine(GetDataDirectory(), "events-cache.json"); + private static readonly TimeZoneInfo EventZone = ResolveEventZone(); + private static readonly HttpClient HttpClient = CreateHttpClient(); + + public async Task> GetEventsAsync(bool forceRefresh, CancellationToken cancellationToken) + { + if (!forceRefresh && TryLoadFromCache(out var cached)) + { + return cached; + } + + try + { + string json = await DownloadJsonAsync(cancellationToken).ConfigureAwait(false); + var events = ParseEvents(json); + SaveCache(json); + return events; + } + catch + { + if (TryLoadFromCache(out var cachedEvents)) + { + return cachedEvents; + } + throw; + } + } + + private static bool TryLoadFromCache(out IReadOnlyList events) + { + events = Array.Empty(); + if (!File.Exists(CachePath)) + { + return false; + } + var age = DateTimeOffset.UtcNow - File.GetLastWriteTimeUtc(CachePath); + if (age > CacheDuration) + { + return false; + } + try + { + string json = File.ReadAllText(CachePath, Encoding.UTF8); + events = ParseEvents(json); + return true; + } + catch + { + return false; + } + } + + private static void SaveCache(string json) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(CachePath)!); + File.WriteAllText(CachePath, json, Encoding.UTF8); + } + catch + { + // Not critical if the cache cannot be persisted. + } + } + + private static async Task DownloadJsonAsync(CancellationToken cancellationToken) + { + using var response = await HttpClient.GetAsync(EventsUri, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + + private static IReadOnlyList ParseEvents(string json) + { + using var document = JsonDocument.Parse(json); + var results = new List(); + foreach (var item in document.RootElement.EnumerateArray()) + { + string title = item.GetPropertyOrDefault("title"); + string time = item.GetPropertyOrDefault("time"); + string category = item.GetPropertyOrDefault("category"); + string status = item.GetPropertyOrDefault("status"); + string link = NormalizeLink(item.GetPropertyOrDefault("link")); + string channelName = ExtractChannelName(link); + long startMillis = ParseEventTime(time); + + results.Add(new LiveEvent(title, time, category, status, link, channelName, startMillis)); + } + return results; + } + + private static string NormalizeLink(string link) + { + if (string.IsNullOrWhiteSpace(link)) + { + return string.Empty; + } + return link.Replace("global1.php", "global2.php", StringComparison.OrdinalIgnoreCase); + } + + private static string ExtractChannelName(string link) + { + if (string.IsNullOrWhiteSpace(link)) + { + return string.Empty; + } + int index = link.IndexOf("stream=", StringComparison.OrdinalIgnoreCase); + if (index < 0 || index + 7 >= link.Length) + { + return string.Empty; + } + return link[(index + 7)..].Replace("_", " ").ToUpperInvariant(); + } + + private static long ParseEventTime(string time) + { + if (string.IsNullOrWhiteSpace(time)) + { + return -1; + } + try + { + var parsed = DateTime.ParseExact(time.Trim(), "HH:mm", CultureInfo.InvariantCulture, DateTimeStyles.None); + var today = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, EventZone).Date; + var localCandidate = new DateTime(today.Year, today.Month, today.Day, parsed.Hour, parsed.Minute, 0, DateTimeKind.Unspecified); + var utcDateTime = TimeZoneInfo.ConvertTimeToUtc(localCandidate, EventZone); + var start = new DateTimeOffset(utcDateTime, TimeSpan.Zero); + if (start < DateTimeOffset.UtcNow.AddHours(-12)) + { + start = start.AddDays(1); + } + return start.ToUnixTimeMilliseconds(); + } + catch + { + return -1; + } + } + + private static string GetDataDirectory() + { + var folder = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "StreamPlayerDesktop"); + Directory.CreateDirectory(folder); + return folder; + } + + private static HttpClient CreateHttpClient() + { + var handler = new HttpClientHandler + { + AutomaticDecompression = System.Net.DecompressionMethods.All + }; + var client = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(15) + }; + client.DefaultRequestHeaders.UserAgent.ParseAdd("StreamPlayerDesktop/1.0"); + client.DefaultRequestHeaders.ConnectionClose = false; + return client; + } + + private static TimeZoneInfo ResolveEventZone() + { + string[] candidates = { "America/Argentina/Buenos_Aires", "Argentina Standard Time" }; + foreach (var id in candidates) + { + try + { + return TimeZoneInfo.FindSystemTimeZoneById(id); + } + catch + { + // try next + } + } + return TimeZoneInfo.Local; + } +} diff --git a/windows/StreamPlayer.Desktop/Services/SectionBuilder.cs b/windows/StreamPlayer.Desktop/Services/SectionBuilder.cs new file mode 100644 index 0000000..617f6f8 --- /dev/null +++ b/windows/StreamPlayer.Desktop/Services/SectionBuilder.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StreamPlayer.Desktop.Models; + +namespace StreamPlayer.Desktop.Services; + +public static class SectionBuilder +{ + public static IReadOnlyList BuildSections() + { + var sections = new List + { + new("Eventos en vivo", SectionType.Events, Array.Empty()) + }; + + var grouped = ChannelRepository.GetChannels() + .GroupBy(channel => DeriveGroupName(channel.Name)) + .ToDictionary(group => group.Key, group => (IReadOnlyList)group.ToList()); + + if (grouped.TryGetValue("ESPN", out var espnGroup)) + { + sections.Add(new ChannelSection("ESPN", SectionType.Channels, espnGroup)); + grouped.Remove("ESPN"); + } + + foreach (var key in grouped.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)) + { + var channels = grouped[key]; + if (channels.Count == 0) + { + continue; + } + sections.Add(new ChannelSection(key, SectionType.Channels, channels)); + } + + sections.Add(new ChannelSection("Todos los canales", SectionType.Channels, ChannelRepository.GetChannels())); + return sections; + } + + private static string DeriveGroupName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return "General"; + } + string upper = name.ToUpperInvariant(); + if (upper.StartsWith("ESPN", StringComparison.Ordinal)) + { + return "ESPN"; + } + if (upper.Contains("FOX SPORTS", StringComparison.Ordinal)) + { + return "Fox Sports"; + } + if (upper.Contains("FOX", StringComparison.Ordinal)) + { + return "Fox"; + } + if (upper.Contains("TNT", StringComparison.Ordinal)) + { + return "TNT"; + } + if (upper.Contains("DAZN", StringComparison.Ordinal)) + { + return "DAZN"; + } + if (upper.Contains("TUDN", StringComparison.Ordinal)) + { + return "TUDN"; + } + if (upper.Contains("TYC", StringComparison.Ordinal)) + { + return "TyC"; + } + if (upper.Contains("GOL", StringComparison.Ordinal)) + { + return "Gol"; + } + int spaceIndex = upper.IndexOf(' '); + return spaceIndex > 0 ? upper[..spaceIndex] : upper; + } +} diff --git a/windows/StreamPlayer.Desktop/Services/StreamUrlResolver.cs b/windows/StreamPlayer.Desktop/Services/StreamUrlResolver.cs new file mode 100644 index 0000000..9cae5ca --- /dev/null +++ b/windows/StreamPlayer.Desktop/Services/StreamUrlResolver.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace StreamPlayer.Desktop.Services; + +/// +/// Replica el resolvedor ofuscado que utiliza la app Android para reconstruir la URL real del stream. +/// +public sealed class StreamUrlResolver +{ + private static readonly Regex ArrayNameRegex = + new(@"var\s+playbackURL\s*=\s*""""\s*,\s*([A-Za-z0-9]+)\s*=\s*\[\]", RegexOptions.Compiled); + + private static readonly Regex EntryRegex = + new(@"\[(\d+),""([A-Za-z0-9+/=]+)""\]", RegexOptions.Compiled); + + private static readonly Regex KeyFunctionsRegex = + new(@"var\s+k=(\w+)\(\)\+(\w+)\(\);", RegexOptions.Compiled); + + private const string FunctionTemplate = @"function\s+{0}\(\)\s*\{{\s*return\s+(\d+);\s*\}}"; + private const string UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) StreamPlayerResolver/1.0"; + + private static readonly HttpClient HttpClient = CreateHttpClient(); + + public async Task ResolveAsync(string pageUrl, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(pageUrl)) + { + throw new ArgumentException("URL inválida", nameof(pageUrl)); + } + + string html = await DownloadPageAsync(pageUrl, cancellationToken).ConfigureAwait(false); + long keyOffset = ExtractKeyOffset(html); + var entries = ExtractEntries(html); + if (entries.Count == 0) + { + throw new InvalidOperationException("No se pudieron obtener los fragmentos del stream."); + } + + var builder = new StringBuilder(); + foreach (var entry in entries) + { + string decoded = Encoding.UTF8.GetString(Convert.FromBase64String(entry.Encoded)); + string numeric = new string(decoded.Where(char.IsDigit).ToArray()); + if (string.IsNullOrEmpty(numeric)) + { + continue; + } + if (!long.TryParse(numeric, out long value)) + { + continue; + } + builder.Append((char)(value - keyOffset)); + } + + string url = builder.ToString(); + if (string.IsNullOrWhiteSpace(url)) + { + throw new InvalidOperationException("No se pudo reconstruir la URL de reproducción."); + } + return url.Trim(); + } + + private static async Task DownloadPageAsync(string url, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.UserAgent.ParseAdd(UserAgent); + request.Headers.Referrer = new Uri("https://streamtpmedia.com/"); + using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + + private static long ExtractKeyOffset(string html) + { + var match = KeyFunctionsRegex.Match(html); + if (!match.Success) + { + throw new InvalidOperationException("No se encontró la clave para el stream."); + } + string first = match.Groups[1].Value; + string second = match.Groups[2].Value; + long firstVal = ExtractReturnValue(html, first); + long secondVal = ExtractReturnValue(html, second); + return firstVal + secondVal; + } + + private static long ExtractReturnValue(string html, string functionName) + { + var pattern = string.Format(CultureInfo.InvariantCulture, FunctionTemplate, Regex.Escape(functionName)); + var regex = new Regex(pattern); + var match = regex.Match(html); + if (!match.Success) + { + throw new InvalidOperationException($"No se encontró el valor de la función {functionName}."); + } + return long.Parse(match.Groups[1].Value); + } + + private static List ExtractEntries(string html) + { + var matcher = ArrayNameRegex.Match(html); + if (!matcher.Success) + { + throw new InvalidOperationException("No se detectó la variable de fragmentos."); + } + string arrayName = matcher.Groups[1].Value; + var arrayRegex = new Regex($"{Regex.Escape(arrayName)}=\\[(.*?)\\];", RegexOptions.Singleline); + var arrayMatch = arrayRegex.Match(html); + if (!arrayMatch.Success) + { + throw new InvalidOperationException("No se encontró el arreglo de fragmentos."); + } + string rawEntries = arrayMatch.Groups[1].Value; + var entries = new List(); + foreach (Match match in EntryRegex.Matches(rawEntries)) + { + if (int.TryParse(match.Groups[1].Value, out int index)) + { + entries.Add(new Entry(index, match.Groups[2].Value)); + } + } + return entries.OrderBy(e => e.Index).ToList(); + } + + private static HttpClient CreateHttpClient() + { + var handler = new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.All, + AllowAutoRedirect = true + }; + return new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(20) + }; + } + + private sealed record Entry(int Index, string Encoded); +} diff --git a/windows/StreamPlayer.Desktop/Services/UpdateService.cs b/windows/StreamPlayer.Desktop/Services/UpdateService.cs new file mode 100644 index 0000000..7255763 --- /dev/null +++ b/windows/StreamPlayer.Desktop/Services/UpdateService.cs @@ -0,0 +1,236 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using StreamPlayer.Desktop.Models; + +namespace StreamPlayer.Desktop.Services; + +public sealed class UpdateService +{ + private static readonly HttpClient HttpClient = new(new HttpClientHandler + { + AutomaticDecompression = DecompressionMethods.All + }) + { + Timeout = TimeSpan.FromSeconds(20) + }; + + public async Task CheckForUpdatesAsync(CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, AppVersion.LatestReleaseApi); + using var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + var root = document.RootElement; + + string tagName = root.GetPropertyOrDefault("tag_name"); + string versionName = DeriveVersionName(tagName, root.GetPropertyOrDefault("name")); + int versionCode = ParseVersionCode(versionName); + string releaseNotes = root.GetPropertyOrDefault("body"); + string releasePageUrl = root.GetPropertyOrDefault("html_url"); + + string downloadUrl = string.Empty; + string downloadFileName = string.Empty; + long sizeBytes = 0; + int downloadCount = 0; + JsonElement assetsElement = default; + bool hasAssets = root.TryGetProperty("assets", out assetsElement) && assetsElement.ValueKind == JsonValueKind.Array; + if (hasAssets && (TryFindAsset(assetsElement, IsApkAsset, out var apkAsset) || + TryGetFirstAsset(assetsElement, out apkAsset))) + { + downloadUrl = apkAsset.GetPropertyOrDefault("browser_download_url"); + downloadFileName = apkAsset.GetPropertyOrDefault("name"); + long.TryParse(apkAsset.GetPropertyOrDefault("size"), out sizeBytes); + int.TryParse(apkAsset.GetPropertyOrDefault("download_count"), out downloadCount); + } + + var manifest = hasAssets + ? await TryFetchManifestAsync(assetsElement, cancellationToken).ConfigureAwait(false) + : null; + if (manifest is not null) + { + versionCode = manifest.Value.GetPropertyOrDefaultInt("versionCode", versionCode); + var manifestVersionName = manifest.Value.GetPropertyOrDefault("versionName"); + if (!string.IsNullOrWhiteSpace(manifestVersionName)) + { + versionName = manifestVersionName; + } + int minSupported = manifest.Value.GetPropertyOrDefaultInt("minSupportedVersionCode", 0); + bool forceUpdate = manifest.Value.GetPropertyOrDefaultBool("forceUpdate", false); + string manifestUrl = manifest.Value.GetPropertyOrDefault("downloadUrl"); + if (!string.IsNullOrWhiteSpace(manifestUrl)) + { + downloadUrl = manifestUrl; + } + string manifestFileName = manifest.Value.GetPropertyOrDefault("fileName"); + if (!string.IsNullOrWhiteSpace(manifestFileName)) + { + downloadFileName = manifestFileName; + } + long manifestSize = manifest.Value.GetPropertyOrDefaultLong("sizeBytes", sizeBytes); + if (manifestSize > 0) + { + sizeBytes = manifestSize; + } + string manifestNotes = manifest.Value.GetPropertyOrDefault("notes"); + if (!string.IsNullOrWhiteSpace(manifestNotes) && string.IsNullOrWhiteSpace(releaseNotes)) + { + releaseNotes = manifestNotes; + } + return BuildInfo(versionCode, versionName, releaseNotes, downloadUrl, downloadFileName, + sizeBytes, downloadCount, releasePageUrl, minSupported, forceUpdate); + } + + return BuildInfo(versionCode, versionName, releaseNotes, downloadUrl, downloadFileName, + sizeBytes, downloadCount, releasePageUrl, 0, false); + } + + private static UpdateInfo? BuildInfo( + int versionCode, + string versionName, + string releaseNotes, + string downloadUrl, + string downloadFileName, + long sizeBytes, + int downloadCount, + string releasePageUrl, + int minSupported, + bool forceUpdate) + { + if (string.IsNullOrWhiteSpace(downloadUrl)) + { + return null; + } + + return new UpdateInfo( + versionCode, + versionName, + releaseNotes, + downloadUrl, + downloadFileName, + sizeBytes, + downloadCount, + releasePageUrl, + minSupported, + forceUpdate); + } + + private static async Task TryFetchManifestAsync(JsonElement assets, CancellationToken cancellationToken) + { + foreach (var asset in assets.EnumerateArray()) + { + string name = asset.GetPropertyOrDefault("name").ToLowerInvariant(); + if (!name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (!name.Contains("update", StringComparison.OrdinalIgnoreCase) && + !name.Contains("manifest", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + string url = asset.GetPropertyOrDefault("browser_download_url"); + if (string.IsNullOrWhiteSpace(url)) + { + continue; + } + try + { + using var manifestResponse = await HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + manifestResponse.EnsureSuccessStatusCode(); + await using var stream = await manifestResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + return document.RootElement.Clone(); + } + catch + { + // Try next manifest candidate. + } + } + return null; + } + + private static bool TryFindAsset(JsonElement assets, Func predicate, out JsonElement asset) + { + if (assets.ValueKind != JsonValueKind.Array) + { + asset = default; + return false; + } + foreach (var candidate in assets.EnumerateArray()) + { + if (predicate(candidate)) + { + asset = candidate; + return true; + } + } + asset = default; + return false; + } + + private static bool IsApkAsset(JsonElement asset) + { + string name = asset.GetPropertyOrDefault("name").ToLowerInvariant(); + return name.EndsWith(".apk", StringComparison.OrdinalIgnoreCase) || + name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); + } + + private static bool TryGetFirstAsset(JsonElement assets, out JsonElement asset) + { + if (assets.ValueKind == JsonValueKind.Array) + { + using var enumerator = assets.EnumerateArray().GetEnumerator(); + if (enumerator.MoveNext()) + { + asset = enumerator.Current; + return true; + } + } + asset = default; + return false; + } + + private static string DeriveVersionName(string tagName, string fallback) + { + string baseName = string.IsNullOrWhiteSpace(tagName) ? fallback : tagName; + if (string.IsNullOrWhiteSpace(baseName)) + { + return string.Empty; + } + return Regex.Replace(baseName, @"^[Vv]", string.Empty).Trim(); + } + + private static int ParseVersionCode(string versionName) + { + if (string.IsNullOrWhiteSpace(versionName)) + { + return -1; + } + var parts = versionName.Split('.', StringSplitOptions.RemoveEmptyEntries); + int major = ParsePart(parts, 0); + int minor = ParsePart(parts, 1); + int patch = ParsePart(parts, 2); + return major * 10000 + minor * 100 + patch; + } + + private static int ParsePart(IReadOnlyList parts, int index) + { + if (index >= parts.Count) + { + return 0; + } + if (int.TryParse(Regex.Replace(parts[index], @"[^\d]", string.Empty), out int value)) + { + return value; + } + return 0; + } +} diff --git a/windows/StreamPlayer.Desktop/Services/WindowsDnsService.cs b/windows/StreamPlayer.Desktop/Services/WindowsDnsService.cs new file mode 100644 index 0000000..1201651 --- /dev/null +++ b/windows/StreamPlayer.Desktop/Services/WindowsDnsService.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net.NetworkInformation; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Threading; +using System.Threading.Tasks; + +namespace StreamPlayer.Desktop.Services; + +public sealed class WindowsDnsService +{ + private static readonly string[] PreferredDns = { "8.8.8.8", "8.8.4.4" }; + private bool _attempted; + + public async Task EnsureGoogleDnsAsync(CancellationToken cancellationToken) + { + if (!OperatingSystem.IsWindows()) + { + return DnsSetupResult.CreateSuccess(); + } + + if (_attempted) + { + return DnsSetupResult.CreateSuccess(); + } + + _attempted = true; + + bool needsElevation = !IsRunningAsAdministrator(); + if (needsElevation) + { + var consent = PromptForElevation(); + if (!consent) + { + return DnsSetupResult.CreateFailure("Se canceló la solicitud de permisos. Ejecuta la app como administrador o configura los DNS manualmente (8.8.8.8 y 8.8.4.4)."); + } + } + + var interfaces = GetEligibleInterfaces().ToList(); + if (interfaces.Count == 0) + { + return DnsSetupResult.CreateSuccess("No se detectaron adaptadores de red activos para forzar DNS."); + } + + foreach (var adapter in interfaces) + { + bool primary = await RunNetshAsync( + $"interface ipv4 set dns name=\"{adapter}\" static {PreferredDns[0]} primary", + cancellationToken, + needsElevation); + bool secondary = await RunNetshAsync( + $"interface ipv4 add dns name=\"{adapter}\" {PreferredDns[1]} index=2", + cancellationToken, + needsElevation); + if (!primary || !secondary) + { + return DnsSetupResult.CreateFailure($"No se pudo configurar DNS para el adaptador \"{adapter}\". Verifica permisos de administrador o configura manualmente los DNS de Google."); + } + } + + return DnsSetupResult.CreateSuccess("DNS de Google aplicados correctamente a los adaptadores de red activos."); + } + + private static bool IsRunningAsAdministrator() + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + + try + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + catch + { + return false; + } + } + + private static IEnumerable GetEligibleInterfaces() + { + return NetworkInterface.GetAllNetworkInterfaces() + .Where(ni => + ni.OperationalStatus == OperationalStatus.Up && + ni.NetworkInterfaceType != NetworkInterfaceType.Loopback && + ni.NetworkInterfaceType != NetworkInterfaceType.Tunnel && + ni.Supports(NetworkInterfaceComponent.IPv4)) + .Select(ni => ni.Name); + } + + private static bool PromptForElevation() + { + try + { + var psi = new ProcessStartInfo + { + FileName = "netsh", + Arguments = "advfirewall show currentprofile", + UseShellExecute = true, + Verb = "runas", + CreateNoWindow = true + }; + using var process = Process.Start(psi); + if (process == null) + { + return false; + } + process.WaitForExit(); + return process.ExitCode == 0; + } + catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223) + { + // User cancelled UAC prompt. + return false; + } + catch + { + return false; + } + } + + private static async Task RunNetshAsync(string arguments, CancellationToken cancellationToken, bool elevate) + { + try + { + var psi = new ProcessStartInfo + { + FileName = "netsh", + Arguments = arguments, + UseShellExecute = elevate, + RedirectStandardOutput = !elevate, + RedirectStandardError = !elevate, + CreateNoWindow = true + }; + if (elevate) + { + psi.Verb = "runas"; + } + + using var process = Process.Start(psi); + if (process == null) + { + return false; + } + await process.WaitForExitAsync(cancellationToken); + return process.ExitCode == 0; + } + catch (System.ComponentModel.Win32Exception ex) when (ex.NativeErrorCode == 1223) + { + return false; + } + catch + { + return false; + } + } +} + +public sealed record DnsSetupResult(bool Success, string Message) +{ + public static DnsSetupResult CreateSuccess(string message = "") => new(true, message); + public static DnsSetupResult CreateFailure(string message) => new(false, message); +} diff --git a/windows/StreamPlayer.Desktop/StreamPlayer.Desktop.csproj b/windows/StreamPlayer.Desktop/StreamPlayer.Desktop.csproj new file mode 100644 index 0000000..9345e60 --- /dev/null +++ b/windows/StreamPlayer.Desktop/StreamPlayer.Desktop.csproj @@ -0,0 +1,36 @@ + + + WinExe + net8.0 + 9.4.6 + 9.4.6.0 + 9.4.6.0 + enable + app.manifest + true + AnyCPU;x64 + + + + + + + + + + + + + + + + + None + All + + + + + + + diff --git a/windows/StreamPlayer.Desktop/StreamPlayer.Desktop.sln b/windows/StreamPlayer.Desktop/StreamPlayer.Desktop.sln new file mode 100644 index 0000000..7cff154 --- /dev/null +++ b/windows/StreamPlayer.Desktop/StreamPlayer.Desktop.sln @@ -0,0 +1,34 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.1.11312.151 d18.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StreamPlayer.Desktop", "StreamPlayer.Desktop.csproj", "{C48AAD92-3333-7DA9-15F3-8709C7FE48C0}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|x64.ActiveCfg = Debug|x64 + {C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|x64.Build.0 = Debug|x64 + {C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Debug|x86.ActiveCfg = Debug|x86 + {C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|Any CPU.Build.0 = Release|Any CPU + {C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|x64.ActiveCfg = Release|x64 + {C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|x64.Build.0 = Release|x64 + {C48AAD92-3333-7DA9-15F3-8709C7FE48C0}.Release|x86.ActiveCfg = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {78A0B5E4-153D-4093-85ED-127C1CDFB1B1} + EndGlobalSection +EndGlobal diff --git a/windows/StreamPlayer.Desktop/ViewLocator.cs b/windows/StreamPlayer.Desktop/ViewLocator.cs new file mode 100644 index 0000000..eccb44b --- /dev/null +++ b/windows/StreamPlayer.Desktop/ViewLocator.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using StreamPlayer.Desktop.ViewModels; + +namespace StreamPlayer.Desktop; + +/// +/// Given a view model, returns the corresponding view if possible. +/// +[RequiresUnreferencedCode( + "Default implementation of ViewLocator involves reflection which may be trimmed away.", + Url = "https://docs.avaloniaui.net/docs/concepts/view-locator")] +public class ViewLocator : IDataTemplate +{ + public Control? Build(object? param) + { + if (param is null) + return null; + + var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + + return new TextBlock { Text = "Not Found: " + name }; + } + + public bool Match(object? data) + { + return data is ViewModelBase; + } +} diff --git a/windows/StreamPlayer.Desktop/ViewModels/MainWindowViewModel.cs b/windows/StreamPlayer.Desktop/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..9fa8bad --- /dev/null +++ b/windows/StreamPlayer.Desktop/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,301 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using StreamPlayer.Desktop.Models; +using StreamPlayer.Desktop.Services; + +namespace StreamPlayer.Desktop.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase +{ + private readonly EventService _eventService = new(); + private readonly UpdateService _updateService = new(); + private readonly DeviceRegistryService _deviceRegistryService = new(); + private readonly WindowsDnsService _dnsService = new(); + private readonly ReadOnlyCollection _sections; + private readonly AsyncRelayCommand _refreshEventsCommand; + private readonly RelayCommand _openChannelCommand; + private readonly RelayCommand _openEventCommand; + + private bool _isInitialized; + private CancellationTokenSource? _eventsCts; + + public ObservableCollection VisibleChannels { get; } = new(); + public ObservableCollection VisibleEvents { get; } = new(); + + [ObservableProperty] + private ChannelSection? selectedSection; + + [ObservableProperty] + private bool isLoading; + + [ObservableProperty] + private bool isShowingEvents; + + [ObservableProperty] + private bool isRefreshingEvents; + + [ObservableProperty] + private bool isDeviceCheckInProgress = true; + + [ObservableProperty] + private bool isDeviceAllowed; + + [ObservableProperty] + private string statusMessage = string.Empty; + + [ObservableProperty] + private string deviceStatusMessage = "Verificando dispositivo…"; + + public ReadOnlyCollection Sections => _sections; + + public string RefreshButtonLabel => IsRefreshingEvents + ? "Actualizando..." + : "Actualizar eventos"; + + public bool IsInteractionLocked => IsDeviceCheckInProgress || !IsDeviceAllowed; + + public IAsyncRelayCommand RefreshEventsCommand => _refreshEventsCommand; + public IRelayCommand OpenChannelCommand => _openChannelCommand; + public IRelayCommand OpenEventCommand => _openEventCommand; + + public event EventHandler? ChannelRequested; + public event EventHandler? ErrorRaised; + public event EventHandler? UpdateAvailable; + public event EventHandler? DeviceStatusEvaluated; + + public MainWindowViewModel() + { + var sections = SectionBuilder.BuildSections().ToList(); + _sections = new ReadOnlyCollection(sections); + _refreshEventsCommand = new AsyncRelayCommand( + () => LoadEventsAsync(forceRefresh: true, CancellationToken.None), + () => IsShowingEvents && !IsRefreshingEvents && CanInteract()); + + _openChannelCommand = new RelayCommand( + channel => + { + if (channel != null) + { + ChannelRequested?.Invoke(this, channel); + } + }, + _ => CanInteract()); + + _openEventCommand = new RelayCommand( + evt => + { + if (evt == null || string.IsNullOrWhiteSpace(evt.PageUrl)) + { + return; + } + ChannelRequested?.Invoke(this, new StreamChannel(evt.Title, evt.PageUrl)); + }, + _ => CanInteract()); + + SelectedSection = _sections.FirstOrDefault(); + } + + public async Task InitializeAsync(CancellationToken cancellationToken) + { + if (_isInitialized) + { + return; + } + + var dnsResult = await _dnsService.EnsureGoogleDnsAsync(cancellationToken); + if (!dnsResult.Success) + { + ErrorRaised?.Invoke(this, dnsResult.Message); + } + else if (!string.IsNullOrWhiteSpace(dnsResult.Message)) + { + StatusMessage = dnsResult.Message; + } + + DnsHelper.WarmUp(); + _isInitialized = true; + if (SelectedSection != null) + { + await LoadSectionAsync(SelectedSection, cancellationToken); + } + } + + public async Task CheckForUpdatesAsync(CancellationToken cancellationToken) + { + try + { + var info = await _updateService.CheckForUpdatesAsync(cancellationToken); + if (info != null && info.IsUpdateAvailable(AppVersion.VersionCode)) + { + UpdateAvailable?.Invoke(this, info); + } + } + catch (Exception ex) + { + ErrorRaised?.Invoke(this, $"No se pudo verificar actualizaciones: {ex.Message}"); + } + } + + public async Task VerifyDeviceAsync(CancellationToken cancellationToken) + { + IsDeviceCheckInProgress = true; + DeviceStatusMessage = "Verificando dispositivo…"; + try + { + var status = await _deviceRegistryService.SyncAsync(cancellationToken); + IsDeviceAllowed = !status.IsBlocked; + DeviceStatusMessage = status.IsBlocked ? "Dispositivo bloqueado" : string.Empty; + DeviceStatusEvaluated?.Invoke(this, status); + } + catch (Exception ex) + { + ErrorRaised?.Invoke(this, $"Error sincronizando dispositivo: {ex.Message}"); + IsDeviceAllowed = true; + DeviceStatusMessage = string.Empty; + } + finally + { + IsDeviceCheckInProgress = false; + } + } + + partial void OnSelectedSectionChanged(ChannelSection? value) + { + IsShowingEvents = value?.IsEvents == true; + _refreshEventsCommand.NotifyCanExecuteChanged(); + if (!_isInitialized || value == null) + { + return; + } + _ = LoadSectionSafeAsync(value); + } + + private async Task LoadSectionAsync(ChannelSection section, CancellationToken cancellationToken) + { + if (section.IsEvents) + { + await LoadEventsAsync(false, cancellationToken); + return; + } + + CancelPendingEvents(); + SetLoading(false); + VisibleEvents.Clear(); + VisibleChannels.Clear(); + foreach (var channel in section.Channels) + { + VisibleChannels.Add(channel); + } + StatusMessage = VisibleChannels.Count == 0 + ? "No hay canales en esta sección." + : string.Empty; + } + + private async Task LoadEventsAsync(bool forceRefresh, CancellationToken cancellationToken) + { + if (SelectedSection?.IsEvents != true) + { + return; + } + CancelPendingEvents(); + _eventsCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var token = _eventsCts.Token; + try + { + SetLoading(true); + IsRefreshingEvents = true; + StatusMessage = string.Empty; + var events = await _eventService.GetEventsAsync(forceRefresh, token); + if (token.IsCancellationRequested) + { + return; + } + VisibleChannels.Clear(); + VisibleEvents.Clear(); + foreach (var evt in events.OrderBy(e => e.StartTimestamp <= 0 ? long.MaxValue : e.StartTimestamp)) + { + VisibleEvents.Add(evt); + } + if (VisibleEvents.Count == 0) + { + StatusMessage = "No hay eventos próximos."; + } + } + catch (OperationCanceledException) + { + // Ignore cancellation. + } + catch (Exception ex) + { + StatusMessage = "No se pudieron cargar los eventos."; + ErrorRaised?.Invoke(this, $"No se pudieron cargar los eventos: {ex.Message}"); + } + finally + { + if (!cancellationToken.IsCancellationRequested) + { + SetLoading(false); + } + IsRefreshingEvents = false; + _refreshEventsCommand.NotifyCanExecuteChanged(); + } + } + + private async Task LoadSectionSafeAsync(ChannelSection section) + { + try + { + await LoadSectionAsync(section, CancellationToken.None); + } + catch (Exception ex) + { + ErrorRaised?.Invoke(this, $"Error al actualizar la sección: {ex.Message}"); + } + } + + private void SetLoading(bool value) + { + IsLoading = value; + } + + private void CancelPendingEvents() + { + if (_eventsCts != null) + { + _eventsCts.Cancel(); + _eventsCts.Dispose(); + _eventsCts = null; + } + } + + private bool CanInteract() => !IsInteractionLocked; + + private void NotifyInteractionChanged() + { + _openChannelCommand.NotifyCanExecuteChanged(); + _openEventCommand.NotifyCanExecuteChanged(); + _refreshEventsCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(IsInteractionLocked)); + } + + partial void OnIsDeviceCheckInProgressChanged(bool value) + { + NotifyInteractionChanged(); + } + + partial void OnIsDeviceAllowedChanged(bool value) + { + NotifyInteractionChanged(); + } + + partial void OnIsRefreshingEventsChanged(bool value) + { + _refreshEventsCommand.NotifyCanExecuteChanged(); + OnPropertyChanged(nameof(RefreshButtonLabel)); + } +} diff --git a/windows/StreamPlayer.Desktop/ViewModels/ViewModelBase.cs b/windows/StreamPlayer.Desktop/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..175400f --- /dev/null +++ b/windows/StreamPlayer.Desktop/ViewModels/ViewModelBase.cs @@ -0,0 +1,7 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace StreamPlayer.Desktop.ViewModels; + +public abstract class ViewModelBase : ObservableObject +{ +} diff --git a/windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml b/windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml new file mode 100644 index 0000000..29d7c0e --- /dev/null +++ b/windows/StreamPlayer.Desktop/Views/BlockedDialog.axaml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +