Add Windows desktop version
This commit is contained in:
90
windows/StreamPlayer.Desktop/Services/ChannelRepository.cs
Normal file
90
windows/StreamPlayer.Desktop/Services/ChannelRepository.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
34
windows/StreamPlayer.Desktop/Services/DnsHelper.cs
Normal file
34
windows/StreamPlayer.Desktop/Services/DnsHelper.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
198
windows/StreamPlayer.Desktop/Services/EventService.cs
Normal file
198
windows/StreamPlayer.Desktop/Services/EventService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
83
windows/StreamPlayer.Desktop/Services/SectionBuilder.cs
Normal file
83
windows/StreamPlayer.Desktop/Services/SectionBuilder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
149
windows/StreamPlayer.Desktop/Services/StreamUrlResolver.cs
Normal file
149
windows/StreamPlayer.Desktop/Services/StreamUrlResolver.cs
Normal 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);
|
||||
}
|
||||
236
windows/StreamPlayer.Desktop/Services/UpdateService.cs
Normal file
236
windows/StreamPlayer.Desktop/Services/UpdateService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
169
windows/StreamPlayer.Desktop/Services/WindowsDnsService.cs
Normal file
169
windows/StreamPlayer.Desktop/Services/WindowsDnsService.cs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user