Add Windows desktop version

This commit is contained in:
renato97
2025-12-17 19:20:55 +00:00
parent 93dbe0941e
commit 8921d7f2a6
36 changed files with 2760 additions and 0 deletions

View File

@@ -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<StreamChannel> Channels = BuildChannels();
public static IReadOnlyList<StreamChannel> GetChannels() => Channels;
private static IReadOnlyList<StreamChannel> BuildChannels()
{
var list = new List<StreamChannel>
{
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();
}
}

View File

@@ -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<DeviceStatus> 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();
}
}

View File

@@ -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.
}
}
});
}
}

View File

@@ -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<IReadOnlyList<LiveEvent>> 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<LiveEvent> events)
{
events = Array.Empty<LiveEvent>();
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<string> 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<LiveEvent> ParseEvents(string json)
{
using var document = JsonDocument.Parse(json);
var results = new List<LiveEvent>();
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;
}
}

View File

@@ -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<ChannelSection> BuildSections()
{
var sections = new List<ChannelSection>
{
new("Eventos en vivo", SectionType.Events, Array.Empty<StreamChannel>())
};
var grouped = ChannelRepository.GetChannels()
.GroupBy(channel => DeriveGroupName(channel.Name))
.ToDictionary(group => group.Key, group => (IReadOnlyList<StreamChannel>)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;
}
}

View File

@@ -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;
/// <summary>
/// Replica el resolvedor ofuscado que utiliza la app Android para reconstruir la URL real del stream.
/// </summary>
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<string> 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<string> 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<Entry> 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<Entry>();
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);
}

View File

@@ -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<UpdateInfo?> 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<JsonElement?> 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<JsonElement, bool> 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<string> 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;
}
}

View File

@@ -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<DnsSetupResult> 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<string> 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<bool> 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);
}