444 lines
16 KiB
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;
|
|
}
|
|
}
|
|
}
|