Files
app/app/src/main/java/com/streamplayer/StreamUrlResolver.java

444 lines
16 KiB
Java

package com.streamplayer;
import android.util.Base64;
import java.io.IOException;
import java.net.InetAddress;
import java.security.cert.X509Certificate;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import okhttp3.Dns;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.dnsoverhttps.DnsOverHttps;
/**
* Resuelve la URL real del stream extrayendo playbackURL de la página.
* Utiliza DNS over HTTPS (Google + Cloudflare) para evitar bloqueos.
* Soporta múltiples formatos de páginas y streams directos.
* Soporta JWPlayer con DRM ClearKey.
*/
public final class StreamUrlResolver {
// Patrón original para streamtp10.com
private static final Pattern PLAYBACK_URL_PATTERN =
Pattern.compile("var\\s+playbackURL\\s*=\\s*[\"']([^\"']+)[\"']");
// Patrón para source src en tags video
private static final Pattern VIDEO_SOURCE_PATTERN =
Pattern.compile("<source[^>]+src=[\"']([^\"']+)[\"']");
// Patrón para URLs M3U8 en cualquier parte del HTML
private static final Pattern M3U8_URL_PATTERN =
Pattern.compile("(https?://[^\\s'\"<>]+\\.m3u8[^\\s'\"<>]*)");
// Patrón para URLs de stream en comillas dobles o simples
private static final Pattern STREAM_URL_PATTERN =
Pattern.compile("['\"](https?://[^'\"<>\\s]+?\\.(?:m3u8|mp4|ts)[^'\"<>\\s]*)['\"]");
// Patrón para file: o url: en JavaScript
private static final Pattern JS_URL_PATTERN =
Pattern.compile("(?:file|url|stream|source)\\s*[:=]\\s*[\"'](https?://[^\"']+)[\"']",
Pattern.CASE_INSENSITIVE);
// Patrón para JWPlayer sources con "file": "url"
private static final Pattern JWPLAYER_FILE_PATTERN =
Pattern.compile("\"file\"\\s*:\\s*\"([^\"]+\\.m3u8[^\"]*)\"");
// Patrón para pares [indice, "base64"] del nuevo playbackURL ofuscado
private static final Pattern OBFUSCATED_PAIR_PATTERN =
Pattern.compile("\\[(\\d+)\\s*,\\s*[\"']([^\"']+)[\"']\\]");
// Patrón para k = fn1() + fn2()
private static final Pattern OBFUSCATED_K_PATTERN =
Pattern.compile("var\\s+k\\s*=\\s*([A-Za-z_$][\\w$]*)\\(\\)\\s*\\+\\s*([A-Za-z_$][\\w$]*)\\(\\)");
// Patrón para function fn() { return 12345; }
private static final Pattern JS_RETURN_NUMBER_FUNCTION_PATTERN =
Pattern.compile("function\\s+([A-Za-z_$][\\w$]*)\\s*\\(\\)\\s*\\{\\s*return\\s*(\\d+)\\s*;?\\s*\\}",
Pattern.DOTALL);
private static final String USER_AGENT = "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
private static final OkHttpClient CLIENT;
static {
OkHttpClient client = null;
try {
final TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[]{};
}
}
};
final SSLContext sslContext = SSLContext.getInstance("SSL");
sslContext.init(null, trustAllCerts, new java.security.SecureRandom());
OkHttpClient.Builder builder = new OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.followRedirects(true)
.sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustAllCerts[0])
.hostnameVerifier((hostname, session) -> true);
OkHttpClient bootstrap = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.sslSocketFactory(sslContext.getSocketFactory(), (X509TrustManager) trustAllCerts[0])
.hostnameVerifier((hostname, session) -> true)
.build();
final DnsOverHttps googleDns = new DnsOverHttps.Builder()
.client(bootstrap)
.url(HttpUrl.get("https://dns.google/dns-query"))
.bootstrapDnsHosts(
InetAddress.getByName("8.8.8.8"),
InetAddress.getByName("8.8.4.4"))
.includeIPv6(false)
.build();
final DnsOverHttps cloudflareDns = new DnsOverHttps.Builder()
.client(bootstrap)
.url(HttpUrl.get("https://cloudflare-dns.com/dns-query"))
.bootstrapDnsHosts(
InetAddress.getByName("1.1.1.1"),
InetAddress.getByName("1.0.0.1"))
.includeIPv6(false)
.build();
builder.dns(hostname -> {
try {
List<InetAddress> result = googleDns.lookup(hostname);
if (result != null && !result.isEmpty()) {
return result;
}
} catch (Exception ignored) {
}
try {
List<InetAddress> result = cloudflareDns.lookup(hostname);
if (result != null && !result.isEmpty()) {
return result;
}
} catch (Exception ignored) {
}
return Dns.SYSTEM.lookup(hostname);
});
client = builder.build();
} catch (Exception e) {
client = new OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.followRedirects(true)
.dns(Dns.SYSTEM)
.build();
}
CLIENT = client;
}
private StreamUrlResolver() {
}
public static String resolve(String pageUrl) throws IOException {
// Primero verificar si la URL ya parece ser un stream directo
if (isDirectStreamUrl(pageUrl)) {
return pageUrl;
}
String html = downloadPage(pageUrl);
// Si el contenido ya parece ser un stream M3U8, retornarlo directamente
if (html.startsWith("#EXTM3U") || html.startsWith("#EXT")) {
return pageUrl;
}
// Intentar múltiples patrones de extracción
String streamUrl = null;
// 1. Patrón original: var playbackURL = "..."
streamUrl = extractWithPattern(html, PLAYBACK_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// 2. Patrón: <source src="...">
streamUrl = extractWithPattern(html, VIDEO_SOURCE_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// 3. Patrón: URLs M3U8 directas
streamUrl = extractWithPattern(html, M3U8_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// 4. Patrón: URLs de stream en comillas
streamUrl = extractWithPattern(html, STREAM_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// 5. Patrón: JavaScript file: / url: / stream:
streamUrl = extractWithPattern(html, JS_URL_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// 6. Patrón: JWPlayer "file": "url.m3u8" (para reproductores web y otros)
streamUrl = extractWithPattern(html, JWPLAYER_FILE_PATTERN);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// 7. Nuevo formato ofuscado: playbackURL generado con atob + fromCharCode
streamUrl = decodeObfuscatedPlaybackUrl(html);
if (isValidStreamUrl(streamUrl)) {
return streamUrl;
}
// Si no encontramos nada con patrones, intentar usar la URL original
// como stream directo (útil para URLs que ya son streams)
if (html.contains(".m3u8") || html.contains("stream") || html.contains("video")) {
return pageUrl;
}
// Último recurso: si la URL viene de sudamericaplay.com o similares,
// intentar usarla directamente
if (pageUrl.contains("sudamericaplay.com") ||
pageUrl.contains("paramount")) {
return pageUrl;
}
// Si no encontramos la URL, mostrar un fragmento del HTML para debug
String preview = html.length() > 500 ? html.substring(0, 500) : html;
throw new IOException("No se encontró la URL del stream en la página. URL: " + pageUrl + ". Vista previa: " + preview);
}
/**
* Decodifica páginas donde playbackURL se arma carácter por carácter con:
* playbackURL += String.fromCharCode(parseInt(atob(v).replace(/\D/g,'')) - k)
*/
private static String decodeObfuscatedPlaybackUrl(String html) {
if (html == null ||
!html.contains("var playbackURL") ||
!html.contains("playbackURL+=") ||
!html.contains("String.fromCharCode") ||
!html.contains("atob(")) {
return null;
}
int scriptStart = html.indexOf("var playbackURL");
if (scriptStart < 0) {
return null;
}
int scriptEnd = html.indexOf("var p2pConfig", scriptStart);
if (scriptEnd < 0) {
scriptEnd = html.indexOf("</script>", scriptStart);
}
if (scriptEnd < 0 || scriptEnd <= scriptStart) {
scriptEnd = Math.min(html.length(), scriptStart + 20000);
}
String script = html.substring(scriptStart, scriptEnd);
Matcher kMatcher = OBFUSCATED_K_PATTERN.matcher(script);
if (!kMatcher.find()) {
return null;
}
String functionA = kMatcher.group(1);
String functionB = kMatcher.group(2);
Map<String, Long> functionValues = new HashMap<>();
Matcher functionMatcher = JS_RETURN_NUMBER_FUNCTION_PATTERN.matcher(script);
while (functionMatcher.find()) {
try {
functionValues.put(functionMatcher.group(1), Long.parseLong(functionMatcher.group(2)));
} catch (NumberFormatException ignored) {
}
}
Long valueA = functionValues.get(functionA);
Long valueB = functionValues.get(functionB);
if (valueA == null || valueB == null) {
return null;
}
long k = valueA + valueB;
List<EncodedPair> pairs = new ArrayList<>();
Matcher pairMatcher = OBFUSCATED_PAIR_PATTERN.matcher(script);
while (pairMatcher.find()) {
try {
int index = Integer.parseInt(pairMatcher.group(1));
pairs.add(new EncodedPair(index, pairMatcher.group(2)));
} catch (NumberFormatException ignored) {
}
}
if (pairs.isEmpty()) {
return null;
}
Collections.sort(pairs, Comparator.comparingInt(pair -> pair.index));
StringBuilder decoded = new StringBuilder(pairs.size());
for (EncodedPair pair : pairs) {
byte[] decodedBytes;
try {
decodedBytes = Base64.decode(pair.encodedValue, Base64.DEFAULT);
} catch (IllegalArgumentException e) {
continue;
}
String decodedText = new String(decodedBytes, StandardCharsets.UTF_8);
String digits = decodedText.replaceAll("\\D", "");
if (digits.isEmpty()) {
continue;
}
long numericValue;
try {
numericValue = Long.parseLong(digits);
} catch (NumberFormatException e) {
continue;
}
long charCode = numericValue - k;
if (charCode < 0 || charCode > Character.MAX_VALUE) {
return null;
}
decoded.append((char) charCode);
}
if (decoded.length() == 0) {
return null;
}
String url = decoded.toString().trim()
.replace("\\/", "/")
.replace("\\u0026", "&")
.replace("\\u002F", "/");
return isValidStreamUrl(url) ? url : null;
}
/**
* Verifica si una URL parece ser un stream directo (M3U8, MP4, etc.)
*/
private static boolean isDirectStreamUrl(String url) {
if (url == null || url.isEmpty()) {
return false;
}
String lower = url.toLowerCase();
return lower.contains(".m3u8") ||
lower.contains(".mpd") ||
lower.contains("stream") && lower.contains(".php") == false ||
lower.endsWith(".mp4") ||
lower.endsWith(".ts");
}
/**
* Verifica si una URL extraída es válida
*/
private static boolean isValidStreamUrl(String url) {
return url != null && !url.isEmpty() && url.startsWith("http");
}
/**
* Extrae la primera coincidencia de un patrón regex
*/
private static String extractWithPattern(String html, Pattern pattern) {
Matcher matcher = pattern.matcher(html);
if (matcher.find()) {
String url = matcher.group(1);
// Limpiar URL de caracteres basura
if (url != null) {
url = url.trim();
// Remover caracteres especiales al final
url = url.replaceAll("[\"'<>\\s].*$", "");
}
return url;
}
return null;
}
/**
* Intenta determinar el tipo de contenido del HTML
*/
private static String getContentTypeFromHtml(String html) {
if (html == null || html.isEmpty()) {
return "unknown";
}
String trimmed = html.trim();
if (trimmed.startsWith("#EXTM3U") || trimmed.startsWith("#EXT")) {
return "m3u8";
}
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return "json";
}
if (trimmed.startsWith("<")) {
return "html";
}
return "text";
}
private static String downloadPage(String pageUrl) throws IOException {
Request request = new Request.Builder()
.url(pageUrl)
.header("User-Agent", USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "es-ES,es;q=0.9,en;q=0.8")
.header("Referer", "http://streamtp10.com/")
.build();
try (Response response = CLIENT.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("Error HTTP " + response.code() + " al cargar la página del stream");
}
if (response.body() == null) {
throw new IOException("Respuesta vacía del servidor");
}
return response.body().string();
}
}
private static final class EncodedPair {
final int index;
final String encodedValue;
EncodedPair(int index, String encodedValue) {
this.index = index;
this.encodedValue = encodedValue;
}
}
}