diff --git a/app/src/main/java/com/horrortv/app/domain/usecase/VideoExtractor.kt b/app/src/main/java/com/horrortv/app/domain/usecase/VideoExtractor.kt index 37a426e..f1b9257 100644 --- a/app/src/main/java/com/horrortv/app/domain/usecase/VideoExtractor.kt +++ b/app/src/main/java/com/horrortv/app/domain/usecase/VideoExtractor.kt @@ -9,151 +9,137 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.jsoup.Jsoup import org.json.JSONArray +import org.json.JSONObject +import java.io.File import java.net.HttpURLConnection import java.net.URL +import java.net.URLEncoder import java.util.zip.GZIPInputStream class VideoExtractor { - + companion object { private const val TAG = "VideoExtractor" - private const val PLAYIMDB_BASE = "https://playimdb.com/title/" - private const val STREAMIMDB_BASE = "https://streamimdb.me/embed/" + private const val PLAYIMDB_BASE = "https://www.playimdb.com/title/" private const val TIMEOUT_MS = 15000 private val ALLOWED_LANGUAGES = setOf("en", "es", "english", "spanish", "eng", "spa") - private val ALLOWED_HOSTS = setOf( - "streamimdb.me", "www.streamimdb.me", - "cloudnestra.com", "www.cloudnestra.com", - "playimdb.com", "www.playimdb.com" - ) private val IMDB_ID_PATTERN = Regex("""^tt\d{7,}${'$'}""") - - private fun isAllowedHost(url: String): Boolean { - return try { ALLOWED_HOSTS.contains(URL(url).host) } catch (_: Exception) { false } - } } - - suspend fun extractVideoSource(imdbId: String): Result = withContext(Dispatchers.IO) { + + suspend fun extractVideoSource(imdbId: String, cacheDir: File? = null): Result = withContext(Dispatchers.IO) { try { if (!IMDB_ID_PATTERN.matches(imdbId)) { return@withContext Result.failure(VideoExtractionException("Invalid IMDb ID: $imdbId")) } Log.d(TAG, "Extracting video for: $imdbId") - - val embedUrl = "$STREAMIMDB_BASE$imdbId" - Log.d(TAG, "Fetching embed: $embedUrl") - - val html = fetchHtml(embedUrl) - Log.d(TAG, "HTML length: ${html.length}") - - var doc = Jsoup.parse(html, embedUrl) - var finalHtml = html - - val iframeUrl = doc.selectFirst("iframe")?.absUrl("src") - if (!iframeUrl.isNullOrEmpty()) { - Log.d(TAG, "Found iframe redirect: $iframeUrl") + + // Step 1: Fetch playimdb.com entry page + val entryUrl = "$PLAYIMDB_BASE$imdbId" + Log.d(TAG, "Step 1: Fetching entry: $entryUrl") + val entryHtml = fetchPage(entryUrl) + + // Step 2: Find iframe URL (could be any domain) + val entryDoc = Jsoup.parse(entryHtml, entryUrl) + val embedUrl = entryDoc.selectFirst("iframe")?.absUrl("src") + if (embedUrl.isNullOrEmpty()) { + return@withContext Result.failure(VideoExtractionException("No embed iframe found")) + } + Log.d(TAG, "Step 2: Embed URL: $embedUrl") + + // Step 3: Fetch embed page and extract CONFIG JSON + val embedHtml = fetchPage(embedUrl) + val config = parsePlayerConfig(embedHtml) + + var videoUrl: String? + var pageUrl: String + + if (config != null && config.containsKey("streamDataApiUrl")) { + // Step 4a: Call the stream data API (vaplayer.ru approach) + val apiUrl = buildStreamApiUrl(config, embedUrl) + Log.d(TAG, "Step 4a: Calling stream API: $apiUrl") + try { - val iframeHtml = fetchHtml(iframeUrl) - Log.d(TAG, "Iframe HTML length: ${iframeHtml.length}") - - val prorcpPattern = Regex("['\"]?src['\"]?\\s*:\\s*['\"]([^'\"]+/prorcp/[^'\"]+)['\"]") - val prorcpMatch = prorcpPattern.find(iframeHtml) - if (prorcpMatch != null) { - val innerUrl = prorcpMatch.groupValues[1] - val fullInnerUrl = if (innerUrl.startsWith("/")) { - "https://cloudnestra.com$innerUrl" - } else if (innerUrl.startsWith("//")) { - "https:$innerUrl" - } else { - innerUrl - } - Log.d(TAG, "Found prorcp URL: $fullInnerUrl") - try { - val innerHtml = fetchHtml(fullInnerUrl) - Log.d(TAG, "Prorcp HTML length: ${innerHtml.length}") - doc = Jsoup.parse(innerHtml, fullInnerUrl) - finalHtml = innerHtml - } catch (e: Exception) { - Log.w(TAG, "Failed prorcp fetch: ${e.message}") - } + val apiResponse = fetchJson(apiUrl, embedUrl) + val streamUrl = extractStreamUrlFromApi(apiResponse) + if (streamUrl != null) { + videoUrl = streamUrl + pageUrl = embedUrl + Log.d(TAG, "Got stream URL from API: $streamUrl") } else { - Log.d(TAG, "No prorcp pattern found, searching for /prorcp/ directly") - val directPattern = Regex("/prorcp/[A-Za-z0-9+/=]+") - val directMatch = directPattern.find(iframeHtml) - if (directMatch != null) { - val innerUrl = directMatch.value - val fullInnerUrl = "https://cloudnestra.com$innerUrl" - Log.d(TAG, "Found prorcp direct: $fullInnerUrl") - try { - val innerHtml = fetchHtml(fullInnerUrl) - Log.d(TAG, "Prorcp direct HTML length: ${innerHtml.length}") - doc = Jsoup.parse(innerHtml, fullInnerUrl) - finalHtml = innerHtml - } catch (e: Exception) { - Log.w(TAG, "Failed prorcp direct fetch: ${e.message}") - } - } + Log.w(TAG, "API returned no stream URLs, falling back to HTML extraction") + videoUrl = null + pageUrl = embedUrl } } catch (e: Exception) { - Log.w(TAG, "Failed to fetch iframe: ${e.message}") - } - } - - val videoUrl = extractVideoUrl(doc) - if (videoUrl == null) { - Log.w(TAG, "No video URL found in HTML") - return@withContext Result.failure(VideoExtractionException("No video URL found")) - } - - Log.d(TAG, "Video URL found: $videoUrl") - - val knownDomains = listOf( - "neonhorizonworkshops.com", - "wanderlynest.com", - "orchidpixelgardens.com", - "cloudnestra.com" - ) - - val finalVideoUrl = if (videoUrl.contains("{v")) { - val basePattern = Regex("""https://tmstr3\.\{v\d+\}/(.+)""") - val pathMatch = basePattern.find(videoUrl) - if (pathMatch != null) { - val path = pathMatch.groupValues[1] - val testDomain = knownDomains.first() - val constructedUrl = "https://tmstr1.$testDomain/$path" - Log.d(TAG, "Constructed URL from known domain: $constructedUrl") - constructedUrl - } else { - videoUrl.replace(Regex("\\{v\\d+\\}"), knownDomains.first()) + Log.w(TAG, "Stream API failed: ${e.message}, falling back to HTML extraction") + videoUrl = null + pageUrl = embedUrl } } else { - videoUrl + // Step 4b: No CONFIG found — old approach, extract from HTML directly + Log.d(TAG, "Step 4b: No CONFIG found, extracting from HTML") + videoUrl = null + pageUrl = embedUrl } - - Log.d(TAG, "Final video URL: $finalVideoUrl") - - val subtitles = extractSubtitles(doc, finalHtml, finalVideoUrl).toMutableList() - - val openSubtitles = fetchSubtitlesFromOpenSubtitles(imdbId) + + // Step 5: If API didn't work, try extracting from HTML (old approach for cloudnestra-style pages) + val finalVideoUrl = videoUrl ?: run { + val doc = Jsoup.parse(embedHtml, embedUrl) + + // Try direct video URL extraction + extractVideoUrl(doc)?.let { return@run it } + + // Try following deeper iframes (old cloudnestra prorcp pattern) + val innerUrls = discoverStreamingUrls(doc, embedUrl) + for (innerUrl in innerUrls) { + try { + Log.d(TAG, "Following inner: $innerUrl") + val innerHtml = fetchPage(innerUrl) + val innerDoc = Jsoup.parse(innerHtml, innerUrl) + extractVideoUrl(innerDoc)?.let { return@run it } + + // One more level + val deepUrls = discoverStreamingUrls(innerDoc, innerUrl) + for (deepUrl in deepUrls) { + try { + val deepHtml = fetchPage(deepUrl) + val deepDoc = Jsoup.parse(deepHtml, deepUrl) + extractVideoUrl(deepDoc)?.let { return@run it } + } catch (_: Exception) {} + } + } catch (_: Exception) {} + } + null + } + + if (finalVideoUrl == null) { + return@withContext Result.failure(VideoExtractionException("No video URL found")) + } + + // Step 6: Resolve video URL template if needed + val resolvedUrl = resolveVideoUrl(finalVideoUrl, pageUrl) + Log.d(TAG, "Final video URL: $resolvedUrl") + + // Step 7: Get subtitles + val doc = Jsoup.parse(embedHtml, embedUrl) + val subtitles = extractSubtitles(doc, embedHtml, resolvedUrl).toMutableList() + val openSubtitles = fetchSubtitlesFromOpenSubtitles(imdbId, cacheDir) for (sub in openSubtitles) { if (!subtitles.any { it.url == sub.url }) { subtitles.add(sub) } } - - // Deduplicate by language - keep first (priority: embed > HLS > OpenSubtitles) - val seen = mutableSetOf() - val dedupedTracks = subtitles.filter { seen.add(it.language.lowercase().trim()) } - + val seenUrls = mutableSetOf() + val dedupedTracks = subtitles.filter { seenUrls.add(it.url) } val filteredSubtitles = dedupedTracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) } Log.d(TAG, "Subtitles found: ${filteredSubtitles.size}") - - val videoType = determineVideoType(finalVideoUrl) + + val videoType = determineVideoType(resolvedUrl) Log.d(TAG, "Video type: $videoType") - + Result.success( VideoSource( - videoUrl = finalVideoUrl, + videoUrl = resolvedUrl, videoType = videoType, subtitleTracks = filteredSubtitles, title = imdbId @@ -166,11 +152,129 @@ class VideoExtractor { Result.failure(VideoExtractionException(e.message ?: "Unknown error", e)) } } - - private fun fetchHtml(url: String): String { - if (!isAllowedHost(url)) { - throw VideoExtractionException("Host not allowed: $url") + + private fun parsePlayerConfig(html: String): Map? { + // Find CONFIG = {...} in the HTML + val configPattern = Regex("""const\s+CONFIG\s*=\s*(\{[^<]+?\})\s*;""") + val match = configPattern.find(html) ?: return null + + val jsonStr = match.groupValues[1] + return try { + val jsonObject = JSONObject(jsonStr) + val map = mutableMapOf() + for (key in jsonObject.keys()) { + val value = jsonObject.get(key) + map[key] = value + } + Log.d(TAG, "Parsed CONFIG: streamDataApiUrl=${map["streamDataApiUrl"]}, mediaId=${map["mediaId"]}, availableSources=${map["availableSources"]}") + map + } catch (e: Exception) { + Log.w(TAG, "Failed to parse CONFIG JSON: ${e.message}") + null } + } + + private fun buildStreamApiUrl(config: Map, embedUrl: String): String { + val mediaId = config["mediaId"] as? String ?: "" + val idType = config["idType"] as? String ?: "imdb" + val mediaType = config["mediaType"] as? String ?: "movie" + val streamDataApiUrl = config["streamDataApiUrl"] as? String ?: "" + val sourceApiUrl = config["sourceApiUrl"] as? String ?: "" + val availableSources = config["availableSources"] + + // Use streamDataApiUrl (absolute URL like https://streamdata.vaplayer.ru/api.php) + val baseUrl = if (streamDataApiUrl.startsWith("http")) { + streamDataApiUrl + } else { + // Relative URL — resolve against embed page + resolveUrl(streamDataApiUrl, embedUrl) + } + + val sb = StringBuilder(baseUrl) + sb.append(if ('?' in baseUrl) '&' else '?') + + if (idType == "imdb") { + sb.append("imdb=").append(URLEncoder.encode(mediaId, "UTF-8")) + } else { + sb.append("tmdb=").append(URLEncoder.encode(mediaId, "UTF-8")) + } + sb.append("&type=").append(mediaType) + + // For TV shows, add season/episode + val season = config["season"] + val episode = config["episode"] + if (mediaType == "tv" && season != null && episode != null) { + sb.append("&season=").append(season).append("&episode=").append(episode) + } + + return sb.toString() + } + + private fun fetchJson(url: String, referer: String): String { + var connection: HttpURLConnection? = null + try { + connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + setRequestProperty("Accept", "application/json, */*") + setRequestProperty("Referer", referer) + instanceFollowRedirects = true + } + + val responseCode = connection.responseCode + Log.d(TAG, "API response code: $responseCode for $url") + + if (responseCode != HttpURLConnection.HTTP_OK) { + throw VideoExtractionException("API HTTP error: $responseCode") + } + + val stream = connection.inputStream.let { s -> + if (connection.contentEncoding?.contains("gzip", ignoreCase = true) == true) { + GZIPInputStream(s) + } else s + } + return stream.bufferedReader().use { it.readText() } + } finally { + connection?.disconnect() + } + } + + private fun extractStreamUrlFromApi(apiResponse: String): String? { + return try { + val json = JSONObject(apiResponse) + val statusCode = json.opt("status_code") + // Handle both string and int status codes + val isSuccess = statusCode == "200" || statusCode == 200 || statusCode.toString() == "200" + + if (!isSuccess) { + Log.w(TAG, "API returned status: $statusCode") + return null + } + + val data = json.optJSONObject("data") ?: return null + val streamUrls = data.optJSONArray("stream_urls") ?: return null + + // Return the first stream URL + for (i in 0 until streamUrls.length()) { + val url = streamUrls.getString(i) + if (url.contains(".m3u8") || url.contains(".mp4")) { + return url + } + } + // If no known extension, return first URL anyway + if (streamUrls.length() > 0) return streamUrls.getString(0) + + null + } catch (e: Exception) { + Log.w(TAG, "Failed to parse stream API response: ${e.message}") + null + } + } + + private fun fetchPage(url: String): String { + require(url.startsWith("https://")) { "Only HTTPS URLs are allowed: $url" } return doHttpFetch(url) } @@ -218,7 +322,146 @@ class VideoExtractor { connection?.disconnect() } } - + + private fun discoverStreamingUrls(doc: org.jsoup.nodes.Document, baseUrl: String): List { + val urls = mutableListOf() + val seen = mutableSetOf() + + // 1. Find all iframes + for (iframe in doc.select("iframe")) { + val src = iframe.absUrl("src").ifEmpty { resolveUrl(iframe.attr("src"), baseUrl) } + if (src.isNotEmpty() && src.startsWith("https://") && seen.add(src)) { + urls.add(src) + Log.d(TAG, "Discovered iframe: $src") + } + } + + // 2. Find JavaScript-generated player URLs (like prorcp patterns) + for (script in doc.select("script")) { + val content = script.html() + + // Pattern: src: "/prorcp/..." or src: "https://..." + val srcPatterns = listOf( + Regex("""['"]?src['"]?\s*:\s*['"]([^'"]+)['"]"""), + Regex("""['"]?file['"]?\s*:\s*['"]([^'"]+)['"]"""), + Regex("""['"]?source['"]?\s*:\s*['"]([^'"]+)['"]""") + ) + + for (pattern in srcPatterns) { + pattern.findAll(content).forEach { match -> + val rawUrl = match.groupValues[1] + val resolved = resolveUrl(rawUrl, baseUrl) + if (resolved.startsWith("https://") && !isValidVideoUrl(resolved) && seen.add(resolved)) { + urls.add(resolved) + Log.d(TAG, "Discovered JS URL: $resolved") + } + } + } + + // Direct path patterns like /prorcp/..., /player/..., /embed/... + val pathPatterns = listOf( + Regex("""(/(?:prorcp|player|embed|rcp)/[A-Za-z0-9+/=_\-]+)"""), + Regex("""['"]((/(?:prorcp|player|embed|rcp)/[^'"]+))['"]""") + ) + + for (pattern in pathPatterns) { + pattern.findAll(content).forEach { match -> + val rawPath = match.groupValues[1] + val resolved = resolveUrl(rawPath, baseUrl) + if (resolved.startsWith("https://") && seen.add(resolved)) { + urls.add(resolved) + Log.d(TAG, "Discovered path: $resolved") + } + } + } + } + + // 3. Find anchor links containing embed/stream + for (a in doc.select("a[href]")) { + val href = a.attr("href") + if (href.contains("embed", ignoreCase = true) || href.contains("stream", ignoreCase = true)) { + val resolved = resolveUrl(href, baseUrl) + if (resolved.startsWith("https://") && seen.add(resolved)) { + urls.add(resolved) + Log.d(TAG, "Discovered anchor: $resolved") + } + } + } + + return urls + } + + private fun resolveUrl(raw: String, baseUrl: String): String { + if (raw.startsWith("https://") || raw.startsWith("http://")) return raw + if (raw.startsWith("//")) return "https:$raw" + if (raw.startsWith("/")) { + return try { + val base = URL(baseUrl) + "${base.protocol}://${base.host}$raw" + } catch (_: Exception) { raw } + } + return try { + URL(URL(baseUrl), raw).toString() + } catch (_: Exception) { raw } + } + + private fun resolveVideoUrl(videoUrl: String, pageUrl: String): String { + if (!videoUrl.contains("{v")) return videoUrl + + return try { + val pageHost = URL(pageUrl).host + // Pattern: https://tmstr3.{v1}/path -> https://tmstr1.actualdomain/path + val templatePattern = Regex("""https?://([^/]+)\.\{v\d+\}/(.+)""") + val match = templatePattern.find(videoUrl) + if (match != null) { + val prefix = match.groupValues[1] + val path = match.groupValues[2] + "https://${prefix.substringBefore('.')}.$pageHost/$path" + } else { + videoUrl.replace(Regex("""\{v\d+\}"""), pageHost) + } + } catch (_: Exception) { + videoUrl + } + } + + private fun buildResult( + rawVideoUrl: String, + doc: org.jsoup.nodes.Document, + html: String, + imdbId: String, + cacheDir: File?, + pageUrl: String + ): Result { + val videoUrl = resolveVideoUrl(rawVideoUrl, pageUrl) + Log.d(TAG, "Video URL found: $videoUrl") + + val subtitles = extractSubtitles(doc, html, videoUrl).toMutableList() + val openSubtitles = fetchSubtitlesFromOpenSubtitles(imdbId, cacheDir) + for (sub in openSubtitles) { + if (!subtitles.any { it.url == sub.url }) { + subtitles.add(sub) + } + } + + val seenUrls = mutableSetOf() + val dedupedTracks = subtitles.filter { seenUrls.add(it.url) } + val filteredSubtitles = dedupedTracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) } + Log.d(TAG, "Subtitles found: ${filteredSubtitles.size}") + + val videoType = determineVideoType(videoUrl) + Log.d(TAG, "Video type: $videoType") + + return Result.success( + VideoSource( + videoUrl = videoUrl, + videoType = videoType, + subtitleTracks = filteredSubtitles, + title = imdbId + ) + ) + } + private fun extractVideoUrl(doc: org.jsoup.nodes.Document): String? { val videoElement = doc.selectFirst("video") if (videoElement != null) { @@ -227,7 +470,7 @@ class VideoExtractor { Log.d(TAG, "Found video[src]: $src") return src } - + val sourceElements = videoElement.select("source") for (source in sourceElements) { val srcAttr = source.absUrl("src") @@ -237,7 +480,7 @@ class VideoExtractor { } } } - + val scripts = doc.select("script") for (script in scripts) { val scriptContent = script.html() @@ -249,20 +492,20 @@ class VideoExtractor { } } } - + val allElements = doc.select("*[src], *[href], *[data-src], *[data-url]") for (elem in allElements) { val possibleUrl = elem.attr("src") .ifEmpty { elem.attr("href") } .ifEmpty { elem.attr("data-src") } .ifEmpty { elem.attr("data-url") } - + if (possibleUrl.isNotEmpty() && isValidVideoUrl(possibleUrl)) { Log.d(TAG, "Found URL in element: $possibleUrl") return possibleUrl } } - + val htmlText = doc.html() val m3u8Pattern = Regex("https?://[^\"'<>\\s]+\\.m3u8[^\"'<>\\s]*") val m3u8Match = m3u8Pattern.find(htmlText) @@ -270,17 +513,17 @@ class VideoExtractor { Log.d(TAG, "Found .m3u8 via regex: ${m3u8Match.value}") return m3u8Match.value } - + val mp4Pattern = Regex("https?://[^\"'<>\\s]+\\.mp4[^\"'<>\\s]*") val mp4Match = mp4Pattern.find(htmlText) if (mp4Match != null) { Log.d(TAG, "Found .mp4 via regex: ${mp4Match.value}") return mp4Match.value } - + return null } - + private fun extractUrlsFromScript(scriptContent: String): List { val urlPattern = Regex("https?://[^\"'<>\\s]+") return urlPattern.findAll(scriptContent) @@ -288,14 +531,14 @@ class VideoExtractor { .filter { isValidVideoUrl(it) } .toList() } - + private fun isValidVideoUrl(url: String): Boolean { val lower = url.lowercase() return (lower.contains(".m3u8") || lower.contains(".mp4") || lower.contains(".mkv") || lower.contains(".mpd")) && (url.startsWith("http://") || url.startsWith("https://")) && !lower.contains(".js") && !lower.contains(".css") && !lower.contains(".html") } - + private fun determineVideoType(url: String): VideoType { val path = try { URL(url).path.lowercase() } catch (_: Exception) { url.lowercase() } return when { @@ -305,17 +548,17 @@ class VideoExtractor { else -> VideoType.UNKNOWN } } - + private fun extractSubtitles(doc: org.jsoup.nodes.Document, htmlContent: String, videoUrl: String?): List { val tracks = mutableListOf() - + val trackElements = doc.select("track") for (track in trackElements) { val src = track.absUrl("src") val srclang = track.attr("srclang").lowercase() val label = track.attr("label").ifEmpty { srclang } val isDefault = track.hasAttr("default") - + if (src.isNotEmpty() && isAllowedLanguage(srclang, label)) { tracks.add( SubtitleTrack( @@ -329,7 +572,7 @@ class VideoExtractor { Log.d(TAG, "Found track element subtitle: $srclang - $src") } } - + val subtitlePatterns = listOf( Regex("\"sub\"\\s*:\\s*\"([^\"]+)\""), Regex("\"subtitle\"\\s*:\\s*\"([^\"]+)\""), @@ -340,7 +583,7 @@ class VideoExtractor { Regex("'subtitle'\\s*:\\s*'([^']+)'"), Regex("'captions'\\s*:\\s*'([^']+)'") ) - + for (pattern in subtitlePatterns) { pattern.findAll(htmlContent).forEach { match -> val url = match.groupValues[1] @@ -361,7 +604,7 @@ class VideoExtractor { } } } - + val vttPattern = Regex("https?://[^\"'<>\\s]+\\.vtt[^\"'<>\\s]*") vttPattern.findAll(htmlContent).forEach { match -> val url = match.value @@ -378,7 +621,7 @@ class VideoExtractor { Log.d(TAG, "Found VTT URL: $url") } } - + val srtPattern = Regex("https?://[^\"'<>\\s]+\\.srt[^\"'<>\\s]*") srtPattern.findAll(htmlContent).forEach { match -> val url = match.value @@ -395,7 +638,7 @@ class VideoExtractor { Log.d(TAG, "Found SRT URL: $url") } } - + val jsonArrayPattern = Regex("\\[[\\s\\S]*?\"url\"[\\s\\S]*?\\]") jsonArrayPattern.findAll(htmlContent).forEach { match -> try { @@ -403,15 +646,15 @@ class VideoExtractor { val urlPattern = Regex("\"url\"\\s*:\\s*\"([^\"]+)\"") val langPattern = Regex("\"lang\"\\s*:\\s*\"([^\"]+)\"") val labelPattern = Regex("\"label\"\\s*:\\s*\"([^\"]+)\"") - + urlPattern.findAll(jsonArray).forEach { urlMatch -> val subUrl = urlMatch.groupValues[1] val langMatch = langPattern.find(jsonArray) val labelMatch = labelPattern.find(jsonArray) - + val lang = langMatch?.groupValues?.get(1)?.lowercase() ?: "en" val subLabel = labelMatch?.groupValues?.get(1) ?: lang - + if (subUrl.isNotEmpty() && !tracks.any { it.url == subUrl } && isAllowedLanguage(lang, subLabel)) { tracks.add( SubtitleTrack( @@ -429,12 +672,12 @@ class VideoExtractor { Log.w(TAG, "Error parsing subtitle JSON array: ${e.message}") } } - + val jwplayerPattern = Regex("\\{[^}]*file:\"([^\"]+)\"[^}]*label:\"([^\"]+)\"[^}]*kind:\"captions\"[^}]*\\}") jwplayerPattern.findAll(htmlContent).forEach { match -> val url = match.groupValues[1] val label = match.groupValues[2].lowercase() - + if (url.isNotEmpty() && !tracks.any { it.url == url } && isAllowedLanguage(label, label)) { tracks.add( SubtitleTrack( @@ -448,7 +691,7 @@ class VideoExtractor { Log.d(TAG, "Found JWPlayer subtitle: $label - $url") } } - + if (videoUrl != null && videoUrl.contains(".m3u8")) { val hlsSubtitles = extractHlsSubtitles(videoUrl) for (sub in hlsSubtitles) { @@ -458,16 +701,16 @@ class VideoExtractor { } } } - + return tracks.filter { isAllowedLanguage(it.language.lowercase(), it.label.lowercase()) } } - + private fun extractHlsSubtitles(videoUrl: String): List { val tracks = mutableListOf() - + try { val manifestContent = fetchManifest(videoUrl) - + val subtitleMediaPattern = Regex( "#EXT-X-MEDIA:TYPE=SUBTITLES[^\\n]*LANGUAGE=\"([^\"]+)\"[^\\n]*NAME=\"([^\"]+)\"[^\\n]*(?:DEFAULT=YES)?[^\\n]*URI=\"([^\"]+)\"" ) @@ -475,14 +718,14 @@ class VideoExtractor { val lang = match.groupValues[1].lowercase() val name = match.groupValues[2] val uri = match.groupValues[3] - + val fullUrl = if (uri.startsWith("http")) { uri } else { val baseUrl = videoUrl.substringBeforeLast("/") + "/" baseUrl + uri } - + if (fullUrl.isNotEmpty() && isAllowedLanguage(lang, name)) { tracks.add( SubtitleTrack( @@ -495,18 +738,18 @@ class VideoExtractor { ) } } - - val simpleSubtitlePattern = Regex("#EXT-X-MEDIA:TYPE=SUBTITLES[^\n]*") + + val simpleSubtitlePattern = Regex("#EXT-X-MEDIA:TYPE=SUBTITLES[^\\n]*") simpleSubtitlePattern.findAll(manifestContent).forEach { match -> val line = match.value val langMatch = Regex("LANGUAGE=\"([^\"]+)\"").find(line) val nameMatch = Regex("NAME=\"([^\"]+)\"").find(line) val uriMatch = Regex("URI=\"([^\"]+)\"").find(line) - + val lang = langMatch?.groupValues?.get(1)?.lowercase() ?: "en" val name = nameMatch?.groupValues?.get(1) ?: lang val uri = uriMatch?.groupValues?.get(1) - + if (uri != null) { val fullUrl = if (uri.startsWith("http")) { uri @@ -514,7 +757,7 @@ class VideoExtractor { val baseUrl = videoUrl.substringBeforeLast("/") + "/" baseUrl + uri } - + if (fullUrl.isNotEmpty() && isAllowedLanguage(lang, name) && !tracks.any { it.url == fullUrl }) { tracks.add( SubtitleTrack( @@ -531,17 +774,16 @@ class VideoExtractor { } catch (e: Exception) { Log.w(TAG, "Failed to extract HLS subtitles: ${e.message}") } - + return tracks } - - fun fetchSubtitlesFromOpenSubtitles(imdbId: String): List { + + fun fetchSubtitlesFromOpenSubtitles(imdbId: String, cacheDir: File? = null): List { val tracks = mutableListOf() val numericId = imdbId.removePrefix("tt").trim() if (numericId.isEmpty()) return tracks val languages = listOf( - "eng" to "en", "spa" to "es" ) @@ -553,23 +795,49 @@ class VideoExtractor { for (i in 0 until jsonArray.length()) { val obj = jsonArray.getJSONObject(i) val downloadLink = obj.optString("SubDownloadLink", "") - val subFormat = obj.optString("SubFormat", "srt") val languageName = obj.optString("LanguageName", normalizedLang) val isHearingImpaired = obj.optString("SubHearingImpaired", "0") == "1" if (isHearingImpaired) continue - if (downloadLink.isNotEmpty() && tracks.none { it.language == normalizedLang }) { + val maxPerLanguage = 10 + val currentCount = tracks.count { it.language == normalizedLang } + + if (downloadLink.isNotEmpty() && currentCount < maxPerLanguage) { + val rawFileName = obj.optString("SubFileName", "") + val displayName = if (rawFileName.isNotEmpty()) { + rawFileName.removeSuffix(".srt").removeSuffix(".sub") + } else { + if (currentCount == 0) languageName else "$languageName #${currentCount + 1}" + } + + val subtitleUrl = if (downloadLink.endsWith(".gz") && cacheDir != null) { + val localFile = File(cacheDir, "subtitle_${normalizedLang}_${currentCount}.srt") + try { + // Delete any previous subtitle file for this slot + if (localFile.exists()) { + localFile.delete() + } + downloadAndDecompressSubtitle(downloadLink, localFile) + "file://${localFile.absolutePath}" + } catch (e: Exception) { + Log.w(TAG, "Failed to decompress subtitle: ${e.message}") + downloadLink + } + } else { + downloadLink + } + tracks.add( SubtitleTrack( - url = downloadLink, + url = subtitleUrl, language = normalizedLang, - label = languageName, - isDefault = false, - mimeType = if (subFormat.equals("vtt", ignoreCase = true)) "text/vtt" else "application/x-subrip" + label = displayName, + isDefault = currentCount == 0, + mimeType = "application/x-subrip" ) ) - Log.d(TAG, "OpenSubtitles track: $normalizedLang - $downloadLink") + Log.d(TAG, "OpenSubtitles track: $normalizedLang - $displayName - $subtitleUrl") } } } catch (e: Exception) { @@ -580,6 +848,32 @@ class VideoExtractor { return tracks } + private fun downloadAndDecompressSubtitle(url: String, outputFile: File) { + val connection = URL(url).openConnection() as HttpURLConnection + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + connection.setRequestProperty("User-Agent", "Mozilla/5.0") + try { + val inputStream = GZIPInputStream(connection.inputStream) + val rawBytes = inputStream.readBytes() + inputStream.close() + + // Decode: try UTF-8 first, fall back to ISO-8859-1 for Spanish/Latin subtitles + val text = String(rawBytes, Charsets.UTF_8) + val hasBadChars = text.contains('\uFFFD') // Unicode replacement character + val decodedText = if (hasBadChars) { + String(rawBytes, Charsets.ISO_8859_1) + } else { + text + } + + // Write as UTF-8 so ExoPlayer can handle it correctly + outputFile.writeText(decodedText, Charsets.UTF_8) + } finally { + connection.disconnect() + } + } + private fun isAllowedLanguage(lang: String, label: String): Boolean { val normalizedLang = lang.lowercase().trim() val normalizedLabel = label.lowercase().trim() @@ -589,7 +883,7 @@ class VideoExtractor { normalizedLabel.contains("english") || normalizedLabel.contains("spanish") || normalizedLabel.contains("español") } - + private fun normalizeLanguage(lang: String): String { val normalized = lang.lowercase().trim() return when { @@ -600,13 +894,13 @@ class VideoExtractor { else -> normalized.take(2) } } - + private fun isSubtitleUrl(url: String): Boolean { - return url.contains(".vtt") || url.contains(".srt") || + return url.contains(".vtt") || url.contains(".srt") || url.contains("subtitle") || url.contains("caption") || url.contains("sub") } - + private fun cleanSubtitleUrl(url: String, baseUrl: String = ""): String { val clean = url.trim().removeSurrounding("\"").removeSurrounding("'").trim() if (clean.isEmpty()) return "" @@ -614,19 +908,19 @@ class VideoExtractor { clean.startsWith("http://") || clean.startsWith("https://") -> clean clean.startsWith("/") -> { try { - val base = URL(baseUrl.ifEmpty { "https://cloudnestra.com" }) - URL(base, clean).toString() - } catch (_: Exception) { "https://cloudnestra.com$clean" } + val base = if (baseUrl.isEmpty()) PLAYIMDB_BASE else baseUrl + URL(URL(base), clean).toString() + } catch (_: Exception) { "" } } else -> { try { - val base = URL(baseUrl.ifEmpty { "https://cloudnestra.com/" }) - URL(base, clean).toString() + val base = if (baseUrl.isEmpty()) PLAYIMDB_BASE else baseUrl + URL(URL(base), clean).toString() } catch (_: Exception) { "" } } } } - + private fun determineMimeType(url: String): String { return when { url.contains(".srt", ignoreCase = true) -> "application/x-subrip" @@ -636,4 +930,4 @@ class VideoExtractor { } } -class VideoExtractionException(message: String, cause: Throwable? = null) : Exception(message, cause) \ No newline at end of file +class VideoExtractionException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/app/src/main/java/com/horrortv/app/presentation/player/PlayerScreen.kt b/app/src/main/java/com/horrortv/app/presentation/player/PlayerScreen.kt index e5ea45d..005d786 100644 --- a/app/src/main/java/com/horrortv/app/presentation/player/PlayerScreen.kt +++ b/app/src/main/java/com/horrortv/app/presentation/player/PlayerScreen.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView @@ -89,7 +90,7 @@ private const val SEEK_STEP_MS = 5000L private val ButtonShape = RoundedCornerShape(12.dp) private val SeekBarShape = RoundedCornerShape(8.dp) -data class SubtitleOption(val label: String, val languageCode: String?) { +data class SubtitleOption(val label: String, val languageCode: String?, val trackIndex: Int = -1) { companion object { val OFF = SubtitleOption("Off", null) } @@ -124,15 +125,9 @@ fun PlayerScreen( val availableSubtitles = remember(videoSource) { buildList { add(SubtitleOption.OFF) - videoSource?.subtitleTracks - ?.map { it.language.lowercase().trim() } - ?.distinct() - ?.forEach { code -> - when (code) { - "en" -> add(SubtitleOption("English", "en")) - "es" -> add(SubtitleOption("Español", "es")) - } - } + videoSource?.subtitleTracks?.forEachIndexed { index, track -> + add(SubtitleOption(track.label, track.language, index)) + } } } @@ -140,7 +135,7 @@ fun PlayerScreen( DefaultTrackSelector(context).apply { setParameters( buildUponParameters() - .setForceHighestSupportedBitrate(true) + .setForceHighestSupportedBitrate(false) .setTunnelingEnabled(false) // was true — breaks subtitles on many TVs ) } @@ -153,10 +148,10 @@ fun PlayerScreen( .setLoadControl( DefaultLoadControl.Builder() .setBufferDurationsMs( - DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * 2, - DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * 2, - 2500, - 5000 + 15_000, // minBuffer: 15s — enough to absorb jitter + 30_000, // maxBuffer: 30s — don't hog RAM on Chromecast + 2_000, // playAfterBuffer: 2s + 3_000 // playAfterRebuffer: 3s — resume faster after stalls ) .build() ) @@ -179,7 +174,8 @@ fun PlayerScreen( } // Register D-pad key handler at Activity level (before PlayerView steals events) - DisposableEffect(Unit) { + // Key: availableSubtitles changes when videoSource loads, handler must see updated list + DisposableEffect(availableSubtitles) { MainActivity.keyEventHandler = handler@{ event -> if (event.action == KeyEvent.ACTION_DOWN) { when (event.keyCode) { @@ -368,12 +364,12 @@ fun PlayerScreen( hasError = false try { val result = withContext(Dispatchers.IO) { - VideoExtractor().extractVideoSource(imdbId) + VideoExtractor().extractVideoSource(imdbId, context.cacheDir) } result.fold( onSuccess = { source -> videoSource = source - val mediaItem = buildMediaItemWithSubtitles(source, selectedSubtitle) + val mediaItem = buildMediaItemWithSubtitles(source) exoPlayer.setMediaItem(mediaItem) exoPlayer.prepare() exoPlayer.playWhenReady = true @@ -489,12 +485,12 @@ fun PlayerScreen( scope.launch { try { val result = withContext(Dispatchers.IO) { - VideoExtractor().extractVideoSource(imdbId) + VideoExtractor().extractVideoSource(imdbId, context.cacheDir) } result.fold( onSuccess = { source -> videoSource = source - val mediaItem = buildMediaItemWithSubtitles(source, selectedSubtitle) + val mediaItem = buildMediaItemWithSubtitles(source) exoPlayer.setMediaItem(mediaItem) exoPlayer.prepare() exoPlayer.playWhenReady = true @@ -632,7 +628,7 @@ private fun tryRequestFocus(focusRequester: FocusRequester, tag: String) { } } -private fun buildMediaItemWithSubtitles(source: VideoSource, selectedSubtitle: SubtitleOption): MediaItem { +private fun buildMediaItemWithSubtitles(source: VideoSource): MediaItem { val builder = MediaItem.Builder() .setUri(source.videoUrl) @@ -641,7 +637,7 @@ private fun buildMediaItemWithSubtitles(source: VideoSource, selectedSubtitle: S .setMimeType(track.mimeType.ifBlank { getSubtitleMimeType(track.url) }) .setLanguage(track.language) .setLabel(track.label) - .setSelectionFlags(if (track.isDefault || selectedSubtitle.languageCode == track.language) C.SELECTION_FLAG_DEFAULT else 0) + .setSelectionFlags(0) .build() } @@ -696,7 +692,9 @@ private fun updateSubtitleTrack(player: ExoPlayer, trackSelector: DefaultTrackSe if (trackGroup.type == C.TRACK_TYPE_TEXT) { for (i in 0 until trackGroup.length) { val format = trackGroup.getTrackFormat(i) - if (format.language?.startsWith(option.languageCode) == true) { + val matchesLanguage = format.language?.startsWith(option.languageCode) == true + val matchesLabel = format.label?.toString() == option.label + if (matchesLanguage && matchesLabel) { parameters.addOverride(TrackSelectionOverride(trackGroup.mediaTrackGroup, listOf(i))) break } @@ -977,6 +975,19 @@ private fun SubtitlePickerOverlay( .background(Color.Black.copy(alpha = 0.7f)), contentAlignment = Alignment.Center ) { + val scrollState = rememberScrollState() + val density = LocalDensity.current + + // Auto-scroll to keep selected item visible + LaunchedEffect(selectedIndex) { + // Each item is 64dp height + 16dp spacing = 80dp + val itemHeightPx = with(density) { 80.dp.toPx() }.toInt() + // Title (~40dp) + spacer (8dp) + padding top (24dp) ≈ 72dp + val headerHeightPx = with(density) { 72.dp.toPx() }.toInt() + val targetOffset = headerHeightPx + (selectedIndex * itemHeightPx) - itemHeightPx + scrollState.animateScrollTo(targetOffset.coerceAtLeast(0)) + } + Column( modifier = Modifier .width(300.dp) @@ -985,12 +996,12 @@ private fun SubtitlePickerOverlay( .background(HorrorColors.HorrorGray) .border(2.dp, HorrorColors.HorrorAccent, RoundedCornerShape(16.dp)) .padding(24.dp) - .verticalScroll(rememberScrollState()), + .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = "Subtitles", + text = "Subtítulos", style = HorrorTypography.DetailTitle, color = HorrorColors.HorrorWhite )