diff --git a/app/build.gradle b/app/build.gradle index ba2abb0..3f7a5d8 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -48,7 +48,9 @@ dependencies { implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.media3:media3-exoplayer: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-datasource-okhttp:1.4.1' // OkHttp con DNS over HTTPS (para StreamUrlResolver) implementation 'com.squareup.okhttp3:okhttp:4.12.0' diff --git a/app/src/main/java/com/streamplayer/ChannelRepository.java b/app/src/main/java/com/streamplayer/ChannelRepository.java index 442df33..6e71b91 100644 --- a/app/src/main/java/com/streamplayer/ChannelRepository.java +++ b/app/src/main/java/com/streamplayer/ChannelRepository.java @@ -8,7 +8,6 @@ import java.util.List; public final class ChannelRepository { - private static final List CHANNELS = createChannels(); private static final Comparator CHANNEL_NAME_COMPARATOR = new Comparator() { @Override @@ -16,6 +15,7 @@ public final class ChannelRepository { return String.CASE_INSENSITIVE_ORDER.compare(left.getName(), right.getName()); } }; + private static final List CHANNELS = createChannels(); private static List createChannels() { List channels = new ArrayList<>(Arrays.asList( diff --git a/app/src/main/java/com/streamplayer/PlayerActivity.java b/app/src/main/java/com/streamplayer/PlayerActivity.java index 5ffb70e..4ea9174 100644 --- a/app/src/main/java/com/streamplayer/PlayerActivity.java +++ b/app/src/main/java/com/streamplayer/PlayerActivity.java @@ -5,6 +5,7 @@ import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.util.Base64; import android.util.Log; import android.view.View; import android.view.WindowManager; @@ -14,16 +15,23 @@ import android.widget.TextView; import androidx.annotation.OptIn; import androidx.appcompat.app.AppCompatActivity; +import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.PlaybackException; import androidx.media3.common.Player; 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.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 java.io.IOException; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -48,7 +56,7 @@ public class PlayerActivity extends AppCompatActivity { private String channelUrl; private boolean overlayVisible = true; private int retryCount = 0; - private String lastStreamUrl; + private StreamUrlResolver.ResolvedStream lastResolvedStream; private String currentChannelPageUrl; private boolean playbackStarted = false; private boolean alternateSourceAttempted = false; @@ -118,14 +126,17 @@ public class PlayerActivity extends AppCompatActivity { new Thread(() -> { try { - String resolvedUrl = StreamUrlResolver.resolve(pageUrl); - Log.d(TAG, "Stream resuelto: " + resolvedUrl + " (req=" + requestGeneration + ")"); + StreamUrlResolver.ResolvedStream resolvedStream = StreamUrlResolver.resolve(pageUrl); + Log.d(TAG, "Stream resuelto: " + resolvedStream.getStreamUrl() + + " | mime=" + resolvedStream.getMimeType() + + " | drm=" + resolvedStream.hasClearKey() + + " (req=" + requestGeneration + ")"); runOnUiThread(() -> { if (!isLatestResolveRequest(requestGeneration)) { Log.d(TAG, "Ignorando resultado viejo (req=" + requestGeneration + ")"); return; } - startPlayback(resolvedUrl); + startPlayback(resolvedStream); }); } catch (IOException e) { runOnUiThread(() -> { @@ -155,28 +166,18 @@ public class PlayerActivity extends AppCompatActivity { } } - private void startPlayback(String streamUrl) { + private void startPlayback(StreamUrlResolver.ResolvedStream resolvedStream) { try { releasePlayer(); - lastStreamUrl = streamUrl; + lastResolvedStream = resolvedStream; retryCount = 0; playbackStarted = false; 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() - .setUserAgent(VlcPlayerConfig.USER_AGENT) - .setAllowCrossProtocolRedirects(true) - .setConnectTimeoutMs(15000) - .setReadTimeoutMs(20000); - - Map 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))); + MediaSource mediaSource = buildMediaSource(resolvedStream); player = new ExoPlayer.Builder(this).build(); 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 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() { if (player == null) { return; @@ -278,8 +358,8 @@ public class PlayerActivity extends AppCompatActivity { }); mainHandler.postDelayed(() -> { - if (lastStreamUrl != null) { - startPlayback(lastStreamUrl); + if (lastResolvedStream != null) { + startPlayback(lastResolvedStream); } else { loadChannel(); } diff --git a/app/src/main/java/com/streamplayer/StreamUrlResolver.java b/app/src/main/java/com/streamplayer/StreamUrlResolver.java index 35a934a..69a5436 100644 --- a/app/src/main/java/com/streamplayer/StreamUrlResolver.java +++ b/app/src/main/java/com/streamplayer/StreamUrlResolver.java @@ -2,6 +2,8 @@ package com.streamplayer; import android.util.Base64; +import androidx.media3.common.MimeTypes; + import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -32,13 +34,13 @@ public final class StreamUrlResolver { private static final Pattern VIDEO_SOURCE_PATTERN = Pattern.compile("]+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 HLS/DASH en cualquier parte del HTML + private static final Pattern STREAM_MANIFEST_URL_PATTERN = + Pattern.compile("(https?://[^\\s'\"<>]+\\.(?:m3u8|mpd)[^\\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]*)['\"]"); + Pattern.compile("['\"](https?://[^'\"<>\\s]+?\\.(?:m3u8|mpd|mp4|ts)[^'\"<>\\s]*)['\"]"); // Patrón para file: o url: en JavaScript private static final Pattern JS_URL_PATTERN = @@ -47,7 +49,8 @@ public final class StreamUrlResolver { // Patrón para JWPlayer sources con "file": "url" 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 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.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() { } - 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 if (isDirectStreamUrl(pageUrl)) { - return pageUrl; + return ResolvedStream.fromUrl(pageUrl); } String html = downloadPage(pageUrl); + String trimmedHtml = html.trim(); - // Si el contenido ya parece ser un stream M3U8, retornarlo directamente - if (html.startsWith("#EXTM3U") || html.startsWith("#EXT")) { - return pageUrl; + // Si el contenido ya es un manifiesto directo, reproducirlo como tal. + if (trimmedHtml.startsWith("#EXTM3U") || trimmedHtml.startsWith("#EXT")) { + return ResolvedStream.hls(pageUrl); + } + if (isDashManifest(trimmedHtml)) { + return ResolvedStream.dash(pageUrl); } // Intentar múltiples patrones de extracción @@ -84,56 +99,56 @@ public final class StreamUrlResolver { // 1. Patrón original: var playbackURL = "..." streamUrl = extractWithPattern(html, PLAYBACK_URL_PATTERN); if (isValidStreamUrl(streamUrl)) { - return streamUrl; + return ResolvedStream.fromUrl(streamUrl); } // 2. Patrón: streamUrl = extractWithPattern(html, VIDEO_SOURCE_PATTERN); if (isValidStreamUrl(streamUrl)) { - return streamUrl; + return ResolvedStream.fromUrl(streamUrl); } - // 3. Patrón: URLs M3U8 directas - streamUrl = extractWithPattern(html, M3U8_URL_PATTERN); + // 3. Patrón: URLs HLS/DASH directas + streamUrl = extractWithPattern(html, STREAM_MANIFEST_URL_PATTERN); if (isValidStreamUrl(streamUrl)) { - return streamUrl; + return ResolvedStream.fromUrl(streamUrl); } // 4. Patrón: URLs de stream en comillas streamUrl = extractWithPattern(html, STREAM_URL_PATTERN); if (isValidStreamUrl(streamUrl)) { - return streamUrl; + return ResolvedStream.fromUrl(streamUrl); } // 5. Patrón: JavaScript file: / url: / stream: streamUrl = extractWithPattern(html, JS_URL_PATTERN); 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); if (isValidStreamUrl(streamUrl)) { - return streamUrl; + return ResolvedStream.fromUrl(streamUrl); } // 7. Nuevo formato ofuscado: playbackURL generado con atob + fromCharCode streamUrl = decodeObfuscatedPlaybackUrl(html); if (isValidStreamUrl(streamUrl)) { - return streamUrl; + return ResolvedStream.fromUrl(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; + // 8. Eventos "transmision*.php": DASH + ClearKey en variables ofuscadas. + ResolvedStream dashClearKeyStream = decodeDashClearKeyStream(html); + if (dashClearKeyStream != null) { + return dashClearKeyStream; } // Último recurso: si la URL viene de sudamericaplay.com o similares, // intentar usarla directamente if (pageUrl.contains("sudamericaplay.com") || pageUrl.contains("paramount")) { - return pageUrl; + return ResolvedStream.fromUrl(pageUrl); } // Si no encontramos la URL, mostrar un fragmento del HTML para debug @@ -194,6 +209,64 @@ public final class StreamUrlResolver { 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("", 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 extractEncodedPairs(String script) { List pairs = new ArrayList<>(); Matcher pairMatcher = OBFUSCATED_PAIR_PATTERN.matcher(script); while (pairMatcher.find()) { @@ -204,18 +277,21 @@ public final class StreamUrlResolver { } } - if (pairs.isEmpty()) { - return null; - } - Collections.sort(pairs, new Comparator() { @Override public int compare(EncodedPair left, EncodedPair right) { return Integer.compare(left.index, right.index); } }); - StringBuilder decoded = new StringBuilder(pairs.size()); + return pairs; + } + private static String decodePairs(List pairs, long k) { + if (pairs.isEmpty()) { + return null; + } + + StringBuilder decoded = new StringBuilder(pairs.size()); for (EncodedPair pair : pairs) { byte[] decodedBytes; try { @@ -249,12 +325,23 @@ public final class StreamUrlResolver { return null; } - String url = decoded.toString().trim() + return decoded.toString().trim() .replace("\\/", "/") .replace("\\u0026", "&") .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("