fix: support streamtp event playback on tv
This commit is contained in:
@@ -48,7 +48,9 @@ dependencies {
|
|||||||
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||||
implementation 'androidx.media3:media3-exoplayer:1.4.1'
|
implementation 'androidx.media3:media3-exoplayer:1.4.1'
|
||||||
implementation 'androidx.media3:media3-exoplayer-hls:1.4.1'
|
implementation 'androidx.media3:media3-exoplayer-hls:1.4.1'
|
||||||
|
implementation 'androidx.media3:media3-exoplayer-dash:1.4.1'
|
||||||
implementation 'androidx.media3:media3-ui:1.4.1'
|
implementation 'androidx.media3:media3-ui:1.4.1'
|
||||||
|
implementation 'androidx.media3:media3-datasource-okhttp:1.4.1'
|
||||||
|
|
||||||
// OkHttp con DNS over HTTPS (para StreamUrlResolver)
|
// OkHttp con DNS over HTTPS (para StreamUrlResolver)
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import java.util.List;
|
|||||||
|
|
||||||
public final class ChannelRepository {
|
public final class ChannelRepository {
|
||||||
|
|
||||||
private static final List<StreamChannel> CHANNELS = createChannels();
|
|
||||||
private static final Comparator<StreamChannel> CHANNEL_NAME_COMPARATOR =
|
private static final Comparator<StreamChannel> CHANNEL_NAME_COMPARATOR =
|
||||||
new Comparator<StreamChannel>() {
|
new Comparator<StreamChannel>() {
|
||||||
@Override
|
@Override
|
||||||
@@ -16,6 +15,7 @@ public final class ChannelRepository {
|
|||||||
return String.CASE_INSENSITIVE_ORDER.compare(left.getName(), right.getName());
|
return String.CASE_INSENSITIVE_ORDER.compare(left.getName(), right.getName());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
private static final List<StreamChannel> CHANNELS = createChannels();
|
||||||
|
|
||||||
private static List<StreamChannel> createChannels() {
|
private static List<StreamChannel> createChannels() {
|
||||||
List<StreamChannel> channels = new ArrayList<>(Arrays.asList(
|
List<StreamChannel> channels = new ArrayList<>(Arrays.asList(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import android.net.Uri;
|
|||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
|
import android.util.Base64;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
@@ -14,16 +15,23 @@ import android.widget.TextView;
|
|||||||
|
|
||||||
import androidx.annotation.OptIn;
|
import androidx.annotation.OptIn;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.media3.common.C;
|
||||||
import androidx.media3.common.MediaItem;
|
import androidx.media3.common.MediaItem;
|
||||||
import androidx.media3.common.PlaybackException;
|
import androidx.media3.common.PlaybackException;
|
||||||
import androidx.media3.common.Player;
|
import androidx.media3.common.Player;
|
||||||
import androidx.media3.common.util.UnstableApi;
|
import androidx.media3.common.util.UnstableApi;
|
||||||
import androidx.media3.datasource.DefaultHttpDataSource;
|
import androidx.media3.datasource.HttpDataSource;
|
||||||
|
import androidx.media3.datasource.okhttp.OkHttpDataSource;
|
||||||
import androidx.media3.exoplayer.ExoPlayer;
|
import androidx.media3.exoplayer.ExoPlayer;
|
||||||
import androidx.media3.exoplayer.hls.HlsMediaSource;
|
import androidx.media3.exoplayer.drm.DefaultDrmSessionManager;
|
||||||
|
import androidx.media3.exoplayer.drm.FrameworkMediaDrm;
|
||||||
|
import androidx.media3.exoplayer.drm.LocalMediaDrmCallback;
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
|
||||||
|
import androidx.media3.exoplayer.source.MediaSource;
|
||||||
import androidx.media3.ui.PlayerView;
|
import androidx.media3.ui.PlayerView;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -48,7 +56,7 @@ public class PlayerActivity extends AppCompatActivity {
|
|||||||
private String channelUrl;
|
private String channelUrl;
|
||||||
private boolean overlayVisible = true;
|
private boolean overlayVisible = true;
|
||||||
private int retryCount = 0;
|
private int retryCount = 0;
|
||||||
private String lastStreamUrl;
|
private StreamUrlResolver.ResolvedStream lastResolvedStream;
|
||||||
private String currentChannelPageUrl;
|
private String currentChannelPageUrl;
|
||||||
private boolean playbackStarted = false;
|
private boolean playbackStarted = false;
|
||||||
private boolean alternateSourceAttempted = false;
|
private boolean alternateSourceAttempted = false;
|
||||||
@@ -118,14 +126,17 @@ public class PlayerActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
try {
|
try {
|
||||||
String resolvedUrl = StreamUrlResolver.resolve(pageUrl);
|
StreamUrlResolver.ResolvedStream resolvedStream = StreamUrlResolver.resolve(pageUrl);
|
||||||
Log.d(TAG, "Stream resuelto: " + resolvedUrl + " (req=" + requestGeneration + ")");
|
Log.d(TAG, "Stream resuelto: " + resolvedStream.getStreamUrl()
|
||||||
|
+ " | mime=" + resolvedStream.getMimeType()
|
||||||
|
+ " | drm=" + resolvedStream.hasClearKey()
|
||||||
|
+ " (req=" + requestGeneration + ")");
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
if (!isLatestResolveRequest(requestGeneration)) {
|
if (!isLatestResolveRequest(requestGeneration)) {
|
||||||
Log.d(TAG, "Ignorando resultado viejo (req=" + requestGeneration + ")");
|
Log.d(TAG, "Ignorando resultado viejo (req=" + requestGeneration + ")");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startPlayback(resolvedUrl);
|
startPlayback(resolvedStream);
|
||||||
});
|
});
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
runOnUiThread(() -> {
|
runOnUiThread(() -> {
|
||||||
@@ -155,28 +166,18 @@ public class PlayerActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startPlayback(String streamUrl) {
|
private void startPlayback(StreamUrlResolver.ResolvedStream resolvedStream) {
|
||||||
try {
|
try {
|
||||||
releasePlayer();
|
releasePlayer();
|
||||||
lastStreamUrl = streamUrl;
|
lastResolvedStream = resolvedStream;
|
||||||
retryCount = 0;
|
retryCount = 0;
|
||||||
playbackStarted = false;
|
playbackStarted = false;
|
||||||
scheduleStartupTimeout();
|
scheduleStartupTimeout();
|
||||||
Log.d(TAG, "Iniciando reproducción: " + streamUrl);
|
Log.d(TAG, "Iniciando reproducción: " + resolvedStream.getStreamUrl()
|
||||||
|
+ " | mime=" + resolvedStream.getMimeType()
|
||||||
|
+ " | drm=" + resolvedStream.hasClearKey());
|
||||||
|
|
||||||
DefaultHttpDataSource.Factory httpFactory = new DefaultHttpDataSource.Factory()
|
MediaSource mediaSource = buildMediaSource(resolvedStream);
|
||||||
.setUserAgent(VlcPlayerConfig.USER_AGENT)
|
|
||||||
.setAllowCrossProtocolRedirects(true)
|
|
||||||
.setConnectTimeoutMs(15000)
|
|
||||||
.setReadTimeoutMs(20000);
|
|
||||||
|
|
||||||
Map<String, String> headers = new HashMap<>();
|
|
||||||
headers.put("User-Agent", VlcPlayerConfig.USER_AGENT);
|
|
||||||
httpFactory.setDefaultRequestProperties(headers);
|
|
||||||
|
|
||||||
HlsMediaSource mediaSource = new HlsMediaSource.Factory(httpFactory)
|
|
||||||
.setAllowChunklessPreparation(true)
|
|
||||||
.createMediaSource(MediaItem.fromUri(Uri.parse(streamUrl)));
|
|
||||||
|
|
||||||
player = new ExoPlayer.Builder(this).build();
|
player = new ExoPlayer.Builder(this).build();
|
||||||
playerView.setPlayer(player);
|
playerView.setPlayer(player);
|
||||||
@@ -195,6 +196,85 @@ public class PlayerActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private MediaSource buildMediaSource(StreamUrlResolver.ResolvedStream resolvedStream) {
|
||||||
|
HttpDataSource.Factory httpFactory = createHttpDataSourceFactory(currentChannelPageUrl);
|
||||||
|
MediaItem.Builder mediaItemBuilder = new MediaItem.Builder()
|
||||||
|
.setUri(Uri.parse(resolvedStream.getStreamUrl()))
|
||||||
|
.setMimeType(resolvedStream.getMimeType());
|
||||||
|
|
||||||
|
DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(httpFactory);
|
||||||
|
if (resolvedStream.hasClearKey()) {
|
||||||
|
mediaItemBuilder.setDrmConfiguration(
|
||||||
|
new MediaItem.DrmConfiguration.Builder(C.CLEARKEY_UUID).build());
|
||||||
|
DefaultDrmSessionManager drmSessionManager = new DefaultDrmSessionManager.Builder()
|
||||||
|
.setUuidAndExoMediaDrmProvider(C.CLEARKEY_UUID, FrameworkMediaDrm.DEFAULT_PROVIDER)
|
||||||
|
.build(new LocalMediaDrmCallback(buildClearKeyLicenseResponse(
|
||||||
|
resolvedStream.getClearKeyIdHex(),
|
||||||
|
resolvedStream.getClearKeyHex())));
|
||||||
|
mediaSourceFactory.setDrmSessionManagerProvider(mediaItem -> drmSessionManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaSourceFactory.createMediaSource(mediaItemBuilder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
private HttpDataSource.Factory createHttpDataSourceFactory(String pageUrl) {
|
||||||
|
Map<String, String> headers = new HashMap<>();
|
||||||
|
headers.put("User-Agent", VlcPlayerConfig.USER_AGENT);
|
||||||
|
headers.put("Accept", "*/*");
|
||||||
|
|
||||||
|
String origin = buildOrigin(pageUrl);
|
||||||
|
if (origin != null) {
|
||||||
|
headers.put("Origin", origin);
|
||||||
|
headers.put("Referer", origin + "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new OkHttpDataSource.Factory(NetworkUtils.getClient())
|
||||||
|
.setUserAgent(VlcPlayerConfig.USER_AGENT)
|
||||||
|
.setDefaultRequestProperties(headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildOrigin(String pageUrl) {
|
||||||
|
if (pageUrl == null || pageUrl.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri uri = Uri.parse(pageUrl);
|
||||||
|
if (uri.getScheme() == null || uri.getHost() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder origin = new StringBuilder()
|
||||||
|
.append(uri.getScheme())
|
||||||
|
.append("://")
|
||||||
|
.append(uri.getHost());
|
||||||
|
if (uri.getPort() != -1) {
|
||||||
|
origin.append(":").append(uri.getPort());
|
||||||
|
}
|
||||||
|
return origin.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] buildClearKeyLicenseResponse(String keyIdHex, String keyHex) {
|
||||||
|
String keyIdBase64Url = encodeBase64Url(hexToBytes(keyIdHex));
|
||||||
|
String keyBase64Url = encodeBase64Url(hexToBytes(keyHex));
|
||||||
|
String response = "{\"keys\":[{\"k\":\"" + keyBase64Url
|
||||||
|
+ "\",\"kid\":\"" + keyIdBase64Url
|
||||||
|
+ "\",\"kty\":\"oct\"}],\"type\":\"temporary\"}";
|
||||||
|
return response.getBytes(StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] hexToBytes(String value) {
|
||||||
|
int length = value.length();
|
||||||
|
byte[] bytes = new byte[length / 2];
|
||||||
|
for (int i = 0; i < length; i += 2) {
|
||||||
|
bytes[i / 2] = (byte) Integer.parseInt(value.substring(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String encodeBase64Url(byte[] value) {
|
||||||
|
return Base64.encodeToString(value, Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE);
|
||||||
|
}
|
||||||
|
|
||||||
private void setupPlayerListener() {
|
private void setupPlayerListener() {
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
return;
|
return;
|
||||||
@@ -278,8 +358,8 @@ public class PlayerActivity extends AppCompatActivity {
|
|||||||
});
|
});
|
||||||
|
|
||||||
mainHandler.postDelayed(() -> {
|
mainHandler.postDelayed(() -> {
|
||||||
if (lastStreamUrl != null) {
|
if (lastResolvedStream != null) {
|
||||||
startPlayback(lastStreamUrl);
|
startPlayback(lastResolvedStream);
|
||||||
} else {
|
} else {
|
||||||
loadChannel();
|
loadChannel();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.streamplayer;
|
|||||||
|
|
||||||
import android.util.Base64;
|
import android.util.Base64;
|
||||||
|
|
||||||
|
import androidx.media3.common.MimeTypes;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -32,13 +34,13 @@ public final class StreamUrlResolver {
|
|||||||
private static final Pattern VIDEO_SOURCE_PATTERN =
|
private static final Pattern VIDEO_SOURCE_PATTERN =
|
||||||
Pattern.compile("<source[^>]+src=[\"']([^\"']+)[\"']");
|
Pattern.compile("<source[^>]+src=[\"']([^\"']+)[\"']");
|
||||||
|
|
||||||
// Patrón para URLs M3U8 en cualquier parte del HTML
|
// Patrón para URLs HLS/DASH en cualquier parte del HTML
|
||||||
private static final Pattern M3U8_URL_PATTERN =
|
private static final Pattern STREAM_MANIFEST_URL_PATTERN =
|
||||||
Pattern.compile("(https?://[^\\s'\"<>]+\\.m3u8[^\\s'\"<>]*)");
|
Pattern.compile("(https?://[^\\s'\"<>]+\\.(?:m3u8|mpd)[^\\s'\"<>]*)");
|
||||||
|
|
||||||
// Patrón para URLs de stream en comillas dobles o simples
|
// Patrón para URLs de stream en comillas dobles o simples
|
||||||
private static final Pattern STREAM_URL_PATTERN =
|
private static final Pattern STREAM_URL_PATTERN =
|
||||||
Pattern.compile("['\"](https?://[^'\"<>\\s]+?\\.(?:m3u8|mp4|ts)[^'\"<>\\s]*)['\"]");
|
Pattern.compile("['\"](https?://[^'\"<>\\s]+?\\.(?:m3u8|mpd|mp4|ts)[^'\"<>\\s]*)['\"]");
|
||||||
|
|
||||||
// Patrón para file: o url: en JavaScript
|
// Patrón para file: o url: en JavaScript
|
||||||
private static final Pattern JS_URL_PATTERN =
|
private static final Pattern JS_URL_PATTERN =
|
||||||
@@ -47,7 +49,8 @@ public final class StreamUrlResolver {
|
|||||||
|
|
||||||
// Patrón para JWPlayer sources con "file": "url"
|
// Patrón para JWPlayer sources con "file": "url"
|
||||||
private static final Pattern JWPLAYER_FILE_PATTERN =
|
private static final Pattern JWPLAYER_FILE_PATTERN =
|
||||||
Pattern.compile("\"file\"\\s*:\\s*\"([^\"]+\\.m3u8[^\"]*)\"");
|
Pattern.compile("\"file\"\\s*:\\s*\"([^\"]+\\.(?:m3u8|mpd)[^\"]*)\"",
|
||||||
|
Pattern.CASE_INSENSITIVE);
|
||||||
|
|
||||||
// Patrón para pares [indice, "base64"] del nuevo playbackURL ofuscado
|
// Patrón para pares [indice, "base64"] del nuevo playbackURL ofuscado
|
||||||
private static final Pattern OBFUSCATED_PAIR_PATTERN =
|
private static final Pattern OBFUSCATED_PAIR_PATTERN =
|
||||||
@@ -62,20 +65,32 @@ public final class StreamUrlResolver {
|
|||||||
Pattern.compile("function\\s+([A-Za-z_$][\\w$]*)\\s*\\(\\)\\s*\\{\\s*return\\s*(\\d+)\\s*;?\\s*\\}",
|
Pattern.compile("function\\s+([A-Za-z_$][\\w$]*)\\s*\\(\\)\\s*\\{\\s*return\\s*(\\d+)\\s*;?\\s*\\}",
|
||||||
Pattern.DOTALL);
|
Pattern.DOTALL);
|
||||||
|
|
||||||
|
// Patrón para IIFEs que calculan k con dos returns inline.
|
||||||
|
private static final Pattern INLINE_OBFUSCATED_K_PATTERN =
|
||||||
|
Pattern.compile("k\\s*=\\s*\\(function\\s+[A-Za-z_$][\\w$]*\\(\\)\\s*\\{\\s*return\\s*(\\d+)\\s*\\}\\)\\(\\)\\s*\\+\\s*\\(function\\s+[A-Za-z_$][\\w$]*\\(\\)\\s*\\{\\s*return\\s*(\\d+)\\s*\\}\\)\\(\\)",
|
||||||
|
Pattern.DOTALL);
|
||||||
|
|
||||||
|
private static final Pattern CLEAR_KEY_HEX_PATTERN =
|
||||||
|
Pattern.compile("^[0-9a-fA-F]{32}$");
|
||||||
|
|
||||||
private StreamUrlResolver() {
|
private StreamUrlResolver() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String resolve(String pageUrl) throws IOException {
|
public static ResolvedStream resolve(String pageUrl) throws IOException {
|
||||||
// Primero verificar si la URL ya parece ser un stream directo
|
// Primero verificar si la URL ya parece ser un stream directo
|
||||||
if (isDirectStreamUrl(pageUrl)) {
|
if (isDirectStreamUrl(pageUrl)) {
|
||||||
return pageUrl;
|
return ResolvedStream.fromUrl(pageUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
String html = downloadPage(pageUrl);
|
String html = downloadPage(pageUrl);
|
||||||
|
String trimmedHtml = html.trim();
|
||||||
|
|
||||||
// Si el contenido ya parece ser un stream M3U8, retornarlo directamente
|
// Si el contenido ya es un manifiesto directo, reproducirlo como tal.
|
||||||
if (html.startsWith("#EXTM3U") || html.startsWith("#EXT")) {
|
if (trimmedHtml.startsWith("#EXTM3U") || trimmedHtml.startsWith("#EXT")) {
|
||||||
return pageUrl;
|
return ResolvedStream.hls(pageUrl);
|
||||||
|
}
|
||||||
|
if (isDashManifest(trimmedHtml)) {
|
||||||
|
return ResolvedStream.dash(pageUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intentar múltiples patrones de extracción
|
// Intentar múltiples patrones de extracción
|
||||||
@@ -84,56 +99,56 @@ public final class StreamUrlResolver {
|
|||||||
// 1. Patrón original: var playbackURL = "..."
|
// 1. Patrón original: var playbackURL = "..."
|
||||||
streamUrl = extractWithPattern(html, PLAYBACK_URL_PATTERN);
|
streamUrl = extractWithPattern(html, PLAYBACK_URL_PATTERN);
|
||||||
if (isValidStreamUrl(streamUrl)) {
|
if (isValidStreamUrl(streamUrl)) {
|
||||||
return streamUrl;
|
return ResolvedStream.fromUrl(streamUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Patrón: <source src="...">
|
// 2. Patrón: <source src="...">
|
||||||
streamUrl = extractWithPattern(html, VIDEO_SOURCE_PATTERN);
|
streamUrl = extractWithPattern(html, VIDEO_SOURCE_PATTERN);
|
||||||
if (isValidStreamUrl(streamUrl)) {
|
if (isValidStreamUrl(streamUrl)) {
|
||||||
return streamUrl;
|
return ResolvedStream.fromUrl(streamUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Patrón: URLs M3U8 directas
|
// 3. Patrón: URLs HLS/DASH directas
|
||||||
streamUrl = extractWithPattern(html, M3U8_URL_PATTERN);
|
streamUrl = extractWithPattern(html, STREAM_MANIFEST_URL_PATTERN);
|
||||||
if (isValidStreamUrl(streamUrl)) {
|
if (isValidStreamUrl(streamUrl)) {
|
||||||
return streamUrl;
|
return ResolvedStream.fromUrl(streamUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Patrón: URLs de stream en comillas
|
// 4. Patrón: URLs de stream en comillas
|
||||||
streamUrl = extractWithPattern(html, STREAM_URL_PATTERN);
|
streamUrl = extractWithPattern(html, STREAM_URL_PATTERN);
|
||||||
if (isValidStreamUrl(streamUrl)) {
|
if (isValidStreamUrl(streamUrl)) {
|
||||||
return streamUrl;
|
return ResolvedStream.fromUrl(streamUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Patrón: JavaScript file: / url: / stream:
|
// 5. Patrón: JavaScript file: / url: / stream:
|
||||||
streamUrl = extractWithPattern(html, JS_URL_PATTERN);
|
streamUrl = extractWithPattern(html, JS_URL_PATTERN);
|
||||||
if (isValidStreamUrl(streamUrl)) {
|
if (isValidStreamUrl(streamUrl)) {
|
||||||
return streamUrl;
|
return ResolvedStream.fromUrl(streamUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Patrón: JWPlayer "file": "url.m3u8" (para reproductores web y otros)
|
// 6. Patrón: JWPlayer "file": "url" (para reproductores web y otros)
|
||||||
streamUrl = extractWithPattern(html, JWPLAYER_FILE_PATTERN);
|
streamUrl = extractWithPattern(html, JWPLAYER_FILE_PATTERN);
|
||||||
if (isValidStreamUrl(streamUrl)) {
|
if (isValidStreamUrl(streamUrl)) {
|
||||||
return streamUrl;
|
return ResolvedStream.fromUrl(streamUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Nuevo formato ofuscado: playbackURL generado con atob + fromCharCode
|
// 7. Nuevo formato ofuscado: playbackURL generado con atob + fromCharCode
|
||||||
streamUrl = decodeObfuscatedPlaybackUrl(html);
|
streamUrl = decodeObfuscatedPlaybackUrl(html);
|
||||||
if (isValidStreamUrl(streamUrl)) {
|
if (isValidStreamUrl(streamUrl)) {
|
||||||
return streamUrl;
|
return ResolvedStream.fromUrl(streamUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si no encontramos nada con patrones, intentar usar la URL original
|
// 8. Eventos "transmision*.php": DASH + ClearKey en variables ofuscadas.
|
||||||
// como stream directo (útil para URLs que ya son streams)
|
ResolvedStream dashClearKeyStream = decodeDashClearKeyStream(html);
|
||||||
if (html.contains(".m3u8") || html.contains("stream") || html.contains("video")) {
|
if (dashClearKeyStream != null) {
|
||||||
return pageUrl;
|
return dashClearKeyStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Último recurso: si la URL viene de sudamericaplay.com o similares,
|
// Último recurso: si la URL viene de sudamericaplay.com o similares,
|
||||||
// intentar usarla directamente
|
// intentar usarla directamente
|
||||||
if (pageUrl.contains("sudamericaplay.com") ||
|
if (pageUrl.contains("sudamericaplay.com") ||
|
||||||
pageUrl.contains("paramount")) {
|
pageUrl.contains("paramount")) {
|
||||||
return pageUrl;
|
return ResolvedStream.fromUrl(pageUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si no encontramos la URL, mostrar un fragmento del HTML para debug
|
// Si no encontramos la URL, mostrar un fragmento del HTML para debug
|
||||||
@@ -194,6 +209,64 @@ public final class StreamUrlResolver {
|
|||||||
|
|
||||||
long k = valueA + valueB;
|
long k = valueA + valueB;
|
||||||
|
|
||||||
|
return decodePairs(extractEncodedPairs(script), k);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ResolvedStream decodeDashClearKeyStream(String html) {
|
||||||
|
if (html == null ||
|
||||||
|
!html.contains("\"type\": \"dash\"") ||
|
||||||
|
!html.contains("clearkey")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String dashUrl = decodeObfuscatedVariable(html, "_u");
|
||||||
|
String keyId = decodeObfuscatedVariable(html, "_ki");
|
||||||
|
String key = decodeObfuscatedVariable(html, "_k");
|
||||||
|
|
||||||
|
if (!isValidStreamUrl(dashUrl) ||
|
||||||
|
!isValidClearKeyHex(keyId) ||
|
||||||
|
!isValidClearKeyHex(key)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ResolvedStream.dashClearKey(dashUrl, keyId, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String decodeObfuscatedVariable(String html, String variableName) {
|
||||||
|
String marker = "var " + variableName + "='';";
|
||||||
|
int start = html.indexOf(marker);
|
||||||
|
if (start < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int end = html.indexOf("var _", start + marker.length());
|
||||||
|
if (end < 0) {
|
||||||
|
end = html.indexOf("var data = jwplayer", start + marker.length());
|
||||||
|
}
|
||||||
|
if (end < 0) {
|
||||||
|
end = html.indexOf("</script>", start + marker.length());
|
||||||
|
}
|
||||||
|
if (end <= start) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String block = html.substring(start, end);
|
||||||
|
Matcher kMatcher = INLINE_OBFUSCATED_K_PATTERN.matcher(block);
|
||||||
|
if (!kMatcher.find()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long k;
|
||||||
|
try {
|
||||||
|
k = Long.parseLong(kMatcher.group(1)) + Long.parseLong(kMatcher.group(2));
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodePairs(extractEncodedPairs(block), k);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<EncodedPair> extractEncodedPairs(String script) {
|
||||||
List<EncodedPair> pairs = new ArrayList<>();
|
List<EncodedPair> pairs = new ArrayList<>();
|
||||||
Matcher pairMatcher = OBFUSCATED_PAIR_PATTERN.matcher(script);
|
Matcher pairMatcher = OBFUSCATED_PAIR_PATTERN.matcher(script);
|
||||||
while (pairMatcher.find()) {
|
while (pairMatcher.find()) {
|
||||||
@@ -204,18 +277,21 @@ public final class StreamUrlResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pairs.isEmpty()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Collections.sort(pairs, new Comparator<EncodedPair>() {
|
Collections.sort(pairs, new Comparator<EncodedPair>() {
|
||||||
@Override
|
@Override
|
||||||
public int compare(EncodedPair left, EncodedPair right) {
|
public int compare(EncodedPair left, EncodedPair right) {
|
||||||
return Integer.compare(left.index, right.index);
|
return Integer.compare(left.index, right.index);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
StringBuilder decoded = new StringBuilder(pairs.size());
|
return pairs;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String decodePairs(List<EncodedPair> pairs, long k) {
|
||||||
|
if (pairs.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder decoded = new StringBuilder(pairs.size());
|
||||||
for (EncodedPair pair : pairs) {
|
for (EncodedPair pair : pairs) {
|
||||||
byte[] decodedBytes;
|
byte[] decodedBytes;
|
||||||
try {
|
try {
|
||||||
@@ -249,12 +325,23 @@ public final class StreamUrlResolver {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
String url = decoded.toString().trim()
|
return decoded.toString().trim()
|
||||||
.replace("\\/", "/")
|
.replace("\\/", "/")
|
||||||
.replace("\\u0026", "&")
|
.replace("\\u0026", "&")
|
||||||
.replace("\\u002F", "/");
|
.replace("\\u002F", "/");
|
||||||
|
}
|
||||||
|
|
||||||
return isValidStreamUrl(url) ? url : null;
|
private static boolean isDashManifest(String body) {
|
||||||
|
if (body == null || body.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String lower = body.toLowerCase(Locale.ROOT);
|
||||||
|
return lower.startsWith("<mpd") ||
|
||||||
|
(lower.startsWith("<?xml") && lower.contains("<mpd"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isValidClearKeyHex(String value) {
|
||||||
|
return value != null && CLEAR_KEY_HEX_PATTERN.matcher(value).matches();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -327,4 +414,76 @@ public final class StreamUrlResolver {
|
|||||||
this.encodedValue = encodedValue;
|
this.encodedValue = encodedValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static final class ResolvedStream {
|
||||||
|
private final String streamUrl;
|
||||||
|
private final String mimeType;
|
||||||
|
private final String clearKeyIdHex;
|
||||||
|
private final String clearKeyHex;
|
||||||
|
|
||||||
|
private ResolvedStream(String streamUrl,
|
||||||
|
String mimeType,
|
||||||
|
String clearKeyIdHex,
|
||||||
|
String clearKeyHex) {
|
||||||
|
this.streamUrl = streamUrl;
|
||||||
|
this.mimeType = mimeType;
|
||||||
|
this.clearKeyIdHex = clearKeyIdHex;
|
||||||
|
this.clearKeyHex = clearKeyHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResolvedStream fromUrl(String streamUrl) {
|
||||||
|
String lower = streamUrl.toLowerCase(Locale.ROOT);
|
||||||
|
if (lower.contains(".mpd")) {
|
||||||
|
return dash(streamUrl);
|
||||||
|
}
|
||||||
|
if (lower.contains(".mp4")) {
|
||||||
|
return progressive(streamUrl, MimeTypes.VIDEO_MP4);
|
||||||
|
}
|
||||||
|
if (lower.contains(".ts")) {
|
||||||
|
return progressive(streamUrl, MimeTypes.VIDEO_MP2T);
|
||||||
|
}
|
||||||
|
return hls(streamUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResolvedStream hls(String streamUrl) {
|
||||||
|
return new ResolvedStream(streamUrl, MimeTypes.APPLICATION_M3U8, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResolvedStream dash(String streamUrl) {
|
||||||
|
return new ResolvedStream(streamUrl, MimeTypes.APPLICATION_MPD, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResolvedStream dashClearKey(String streamUrl,
|
||||||
|
String clearKeyIdHex,
|
||||||
|
String clearKeyHex) {
|
||||||
|
return new ResolvedStream(streamUrl,
|
||||||
|
MimeTypes.APPLICATION_MPD,
|
||||||
|
clearKeyIdHex,
|
||||||
|
clearKeyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ResolvedStream progressive(String streamUrl, String mimeType) {
|
||||||
|
return new ResolvedStream(streamUrl, mimeType, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getStreamUrl() {
|
||||||
|
return streamUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMimeType() {
|
||||||
|
return mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getClearKeyIdHex() {
|
||||||
|
return clearKeyIdHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getClearKeyHex() {
|
||||||
|
return clearKeyHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasClearKey() {
|
||||||
|
return clearKeyIdHex != null && clearKeyHex != null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user