diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/StalkerApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/StalkerApi.kt index 0d18c0f59..5eb293dad 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/api/StalkerApi.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/api/StalkerApi.kt @@ -440,6 +440,106 @@ open class StalkerApi( } } + /** + * Ask the portal itself for movies matching [query] instead of downloading + * the whole catalog first. + * + * A Stalker portal only serves `get_ordered_list` in pages of (typically) + * 14 entries, so mirroring the Xtream approach - fetch the complete catalog, + * index it locally - would cost hundreds of requests per refresh on a large + * portal. `search` narrows the same endpoint server-side, which keeps a + * movie lookup at one request. + * + * Returns an empty list when the portal does not implement VOD listing at + * all: such builds answer with an HTML page or a bare `{"js":""}` under a + * plain HTTP 200, so success is measured on the parsed payload, never on the + * status code. + */ + suspend fun searchVod( + query: String, + maxPages: Int = DEFAULT_VOD_SEARCH_PAGES + ): List? { + require(maxPages > 0) { "maxPages must be positive" } + val term = query.trim() + if (term.isBlank()) return emptyList() + + val results = mutableListOf() + val seenKeys = HashSet() + try { + val encodedTerm = java.net.URLEncoder.encode(term, "UTF-8") + var page = 1 + while (page <= maxPages) { + // `category=0` means "every category" here. The category list + // spells the same idea as `id: "*"`, but get_ordered_list does + // not accept it: a portal that reads `*` as a literal category + // name finds nothing, or drops `search` altogether and answers + // with the head of its catalogue. + // `sortby=name` keeps the matches for one term together. Sorted + // by date added instead, a catalogue of six figures pushes them + // past [maxPages] purely by age. + val url = "$apiBase/server/load.php?type=vod&action=get_ordered_list" + + "&category=0&sortby=name&search=$encodedTerm&p=$page&JsHttpRequest=1-xml" + val response = doGet(url) + val parsed = gson.fromJson(response, StalkerVodResponse::class.java) + val data = parsed?.js?.data ?: break + if (data.isEmpty()) break + + var newEntries = 0 + for (item in data) { + val command = item.cmd?.trim().orEmpty() + if (command.isBlank()) continue + val key = item.id?.trim()?.ifBlank { null } ?: command + if (!seenKeys.add(key)) continue + newEntries++ + results += item + } + + val totalItems = parsed.js?.totalItems ?: 0 + val maxPageItems = (parsed.js?.maxPageItems ?: data.size).coerceAtLeast(1) + // Some portals ignore `p` and answer every page with the same + // result set - stop as soon as a page adds nothing new. A + // portal that reports no total at all keeps paging until then + // or until [maxPages]. + if (newEntries == 0) break + if (totalItems > 0 && data.size >= totalItems) break + if (totalItems > 0 && page * maxPageItems >= totalItems) break + page++ + } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + + System.err.println("[Stalker] VOD search failed: ${e.message}") + // null, not the partial list: the caller caches what it gets back, + // and a failed request must never be stored as "this portal has + // nothing" - see the null contract on the return type. + return null + } + return results + } + + /** + * Exchange a VOD `cmd` for a playable URL (`type=vod&action=create_link`). + * + * Only ever called when playback actually starts - see [StalkerVodItem]. + */ + suspend fun resolveVodStreamUrl(cmd: String): String? { + val command = cmd.trim() + if (command.isBlank()) return null + return try { + val encodedCmd = java.net.URLEncoder.encode(command, "UTF-8") + val url = "$apiBase/server/load.php?type=vod&action=create_link&cmd=$encodedCmd" + + "&forced_storage=undefined&disable_ad=0&JsHttpRequest=1-xml" + val response = doGet(url) + val parsed = gson.fromJson(response, StalkerLinkResponse::class.java) + sanitizePlaybackCommand(parsed?.js?.cmd) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + + System.err.println("[Stalker] Resolve VOD stream failed: ${e.message}") + null + } + } + /** Resolve a channel's cmd to a playable stream URL */ suspend fun resolveStreamUrl(cmd: String): String? { return try { @@ -515,6 +615,60 @@ open class StalkerApi( data class StalkerLinkResponse(val js: StalkerLink?) data class StalkerLink(val cmd: String?) + /** + * One entry of the portal's VOD catalog. + * + * Every field is a String because portals disagree on whether ids, years + * and ratings arrive as JSON numbers or strings; Gson accepts both for a + * String field but throws on a mismatched primitive type, which would lose + * the whole response. [cmd] is the token that has to go through + * `create_link` before it can be played. [tmdbId] is only filled in by some + * portal builds - matching falls back to title and year without it. + */ + data class StalkerVodItem( + val id: String? = null, + val name: String? = null, + val cmd: String? = null, + val year: String? = null, + /** Runtime in minutes on most builds; a few send "hh:mm:ss" instead. */ + val time: String? = null, + /** 1 when the portal flags the entry as HD. Not an actual resolution. */ + val hd: String? = null, + @SerializedName("screenshot_uri") val screenshotUri: String? = null, + @SerializedName("rating_imdb") val ratingImdb: String? = null, + @SerializedName(value = "tmdb_id", alternate = ["tmdb", "tmdbid"]) val tmdbId: String? = null, + @SerializedName("category_id") val categoryId: String? = null + ) + + data class StalkerVodResponse(val js: StalkerVodData?) + data class StalkerVodData( + val data: List?, + @SerializedName("total_items") val totalItems: Int?, + @SerializedName("max_page_items") val maxPageItems: Int? + ) + + companion object { + /** + * Search results are already narrow; a handful of pages is plenty and + * keeps a single lookup from turning into a crawl. + */ + const val DEFAULT_VOD_SEARCH_PAGES = 3 + + /** + * Portals return the playable URL prefixed with the player they expect + * ("ffmpeg http://...", "auto http://..."). Strip that hint, but leave a + * value that is already a bare URL untouched. + */ + fun sanitizePlaybackCommand(raw: String?): String? { + val trimmed = raw?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val separator = trimmed.indexOf(' ') + if (separator <= 0) return trimmed + if (trimmed.substring(0, separator).contains("://")) return trimmed + return trimmed.substring(separator + 1).trim().ifBlank { null } + } + } + data class StalkerEpgResponse(val js: List?) /** Field names vary by portal software/version, hence the alternates. */ diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/IptvVodSourceIds.kt b/app/src/main/kotlin/com/arflix/tv/data/model/IptvVodSourceIds.kt new file mode 100644 index 000000000..3ce6599c8 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/model/IptvVodSourceIds.kt @@ -0,0 +1,24 @@ +package com.arflix.tv.data.model + +import java.util.Locale + +/** + * Add-on ids ARVIO puts on its own IPTV video-on-demand sources. + * + * Xtream and Stalker feed the very same movie/episode source lists, so every + * place that special-cases IPTV VOD has to know about both ids. Checking only + * the Xtream id silently drops the Stalker sources when a source list is + * re-merged, or classifies them as live TV and filters them out. + */ +object IptvVodSourceIds { + + const val XTREAM = "iptv_xtream_vod" + const val STALKER = "iptv_stalker_vod" + + val ALL: Set = setOf(XTREAM, STALKER) + + fun isIptvVodAddonId(addonId: String?): Boolean { + val id = addonId?.trim()?.lowercase(Locale.US) ?: return false + return id in ALL + } +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt b/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt index 20daa0296..59c9297f6 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/Models.kt @@ -34,6 +34,11 @@ data class MediaItem( val badge: String? = null, val genreIds: List = emptyList(), val originalLanguage: String? = null, + // The native TMDB name, kept next to the localized [title] because some + // providers list a title only under its original name. Null when TMDB has + // none, and null on items restored from an older JSON cache - every reader + // must treat it as "unknown", never as "same as the title". + val originalTitle: String? = null, val primaryNetworkLogo: String? = null, val isOngoing: Boolean = false, val totalEpisodes: Int? = null, diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/SportsAddonCapabilities.kt b/app/src/main/kotlin/com/arflix/tv/data/model/SportsAddonCapabilities.kt index 99b2242f0..f24591038 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/SportsAddonCapabilities.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/SportsAddonCapabilities.kt @@ -36,7 +36,7 @@ object SportsAddonCapabilities { // Internal sources that contain "iptv" in their ID but serve on-demand media. // Keep this exact so third-party IPTV/live add-ons remain classified as live. - private val explicitVodStreamAddonIds = setOf("iptv_xtream_vod") + private val explicitVodStreamAddonIds = IptvVodSourceIds.ALL fun isSportsHomeStatus(status: String?): Boolean { val value = status ?: return false diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/StalkerVodLink.kt b/app/src/main/kotlin/com/arflix/tv/data/model/StalkerVodLink.kt new file mode 100644 index 000000000..c6a60b74e --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/model/StalkerVodLink.kt @@ -0,0 +1,64 @@ +package com.arflix.tv.data.model + +import java.net.URLDecoder +import java.net.URLEncoder + +/** + * Placeholder URL for a Stalker VOD source. + * + * A Stalker portal never hands out a playable URL, only a `cmd` token that has + * to be exchanged for one via `create_link`. Doing that while building a source + * list would fire one portal request per candidate, so a matched movie carries + * this marker instead and `StreamRepository.resolveStreamInternal` exchanges it + * exactly once, when the user actually starts playback. + * + * Shape: `stalker_vod:///`. The portal id travels + * inside the marker so a multi-portal setup always resolves against the portal + * the entry came from. + */ +internal object StalkerVodLink { + + const val SCHEME = "stalker_vod://" + + fun isMarker(url: String): Boolean = url.trim().startsWith(SCHEME, ignoreCase = true) + + fun buildMarker(portalId: String, cmd: String): String? { + val id = portalId.trim() + val command = cmd.trim() + if (id.isBlank() || command.isBlank()) return null + return SCHEME + URLEncoder.encode(id, "UTF-8") + "/" + URLEncoder.encode(command, "UTF-8") + } + + /** Returns `portalId to cmd`, or null when [url] is not a well-formed marker. */ + fun parseMarker(url: String): Pair? { + val trimmed = url.trim() + if (!isMarker(trimmed)) return null + val body = trimmed.substring(SCHEME.length) + // The cmd is url-encoded, so its own slashes cannot be confused with + // the single separator between portal id and command. + val separator = body.indexOf('/') + if (separator <= 0 || separator == body.length - 1) return null + val portalId = runCatching { URLDecoder.decode(body.substring(0, separator), "UTF-8") } + .getOrNull()?.trim().orEmpty() + val command = runCatching { URLDecoder.decode(body.substring(separator + 1), "UTF-8") } + .getOrNull()?.trim().orEmpty() + if (portalId.isBlank() || command.isBlank()) return null + return portalId to command + } +} + +/** + * True when [url] plays directly, or is a Stalker VOD placeholder that becomes + * a direct URL the moment playback resolves it. + * + * Everything that filters or ranks "direct http source" has to count the + * placeholder too. Autoplay does: without this, a matched Stalker movie is + * dropped from the autoplay candidates and the player reports "no source + * matches this filter" even though the source list shows it and it plays fine + * when picked by hand. + */ +internal fun isDirectStreamUrl(url: String?): Boolean { + val trimmed = url?.trim().orEmpty() + if (trimmed.isBlank()) return false + return trimmed.startsWith("http", ignoreCase = true) || StalkerVodLink.isMarker(trimmed) +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 63d9cc6be..e0c473edd 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt @@ -8,10 +8,12 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import com.arflix.tv.data.model.IptvChannel import com.arflix.tv.data.model.IptvGuideHistory +import com.arflix.tv.data.model.IptvVodSourceIds import com.arflix.tv.data.model.DrmInfo import com.arflix.tv.data.model.IptvNowNext import com.arflix.tv.data.model.IptvProgram import com.arflix.tv.data.model.IptvSnapshot +import com.arflix.tv.data.model.StalkerVodLink import com.arflix.tv.data.model.StreamSource import com.arflix.tv.R import com.arflix.tv.network.withIptvProviderRequestGuard @@ -189,9 +191,38 @@ internal object IptvTitleNormalizer { private val NON_ALPHA_NUM_REGEX = Regex("[^a-z0-9]+") private val DIACRITICS_REGEX = Regex("\\p{Mn}+") + /** Box-drawing bar some IPTV panels use in place of a pipe: "\u2503DE\u2503 Title". */ + private const val BOX_DRAWING_BAR = '\u2503' + + /** + * Leading language/quality tags in pipes: "|DE| Title", "|DE|HD| Title". + * The tags share their separators, hence one opening bar followed by + * repeated "TAG|" groups rather than repeated "|TAG|" groups. + */ + private val LEADING_PIPE_TAG_REGEX = Regex("""^\s*\|(?:[A-Za-z0-9]{1,6}\|)+\s*""") + + /** + * Leading language marker: "DE: Title", "GER - Title", "EN| Title". + * + * Deliberately an explicit code list instead of a generic two-or-three + * letter prefix: the generic form also eats real titles such as + * "IT: Chapter Two". Codes that double as English words ("it", "no", "se", + * "us") are left out for the same reason. + */ + private val LANGUAGE_PREFIX_REGEX = Regex( + """^(?:de|deu|ger|en|eng|fr|fra|fre|es|esp|spa|pt|por|nl|ned|dut|pl|pol|tr|tur|ar|ara|""" + + """ru|rus|ro|ron|rom|ita|ell|gre|cz|cze|hu|hun|swe|nor|dan|fin|bg|bul|hr|hrv|srp|""" + + """sk|slo|slv|ua|ukr|mk|mkd|vip|multi|dual)\s*[:\-|]\s*""", + RegexOption.IGNORE_CASE + ) + fun normalize(value: String): String { if (value.isBlank()) return "" val stripped = value + .replace(BOX_DRAWING_BAR, '|') + .replace(LEADING_PIPE_TAG_REGEX, " ") + .trimStart() + .replace(LANGUAGE_PREFIX_REGEX, " ") .replace(BRACKET_CONTENT_REGEX, " ") .replace(PAREN_CONTENT_REGEX, " ") .replace(YEAR_PAREN_REGEX, " ") @@ -387,6 +418,44 @@ class IptvRepository @Inject constructor( private val stalkerShortEpgCacheTtlMs = 2 * 60 * 1000L private val stalkerBulkProgramsPerChannelLimit = 16 + private data class StalkerVodSearchCacheKey( + val portalId: String, + val apiIdentity: String, + val query: String + ) + + private data class StalkerVodSearchCacheEntry( + val fetchedAtMs: Long, + val items: List + ) + + /** + * Raw portal answers per search term. The persisted movie-source cache + * already covers "same movie looked up again", this one covers different + * movies that normalize onto the same query and the repeated lookups a + * single detail screen can trigger, so neither hits the portal twice. + */ + private val stalkerVodSearchCache = + ConcurrentHashMap() + private val stalkerVodSearchCacheTtlMs = 6 * 60 * 60_000L + + /** + * A "the portal knows no such title" answer is kept only briefly. It is a + * real answer, so it earns an entry - it stops a browsed-past show from + * asking again on every screen - but six hours is far too long to be wrong + * about: catalogs change, and a title the portal gains today would stay + * invisible for the rest of the day. + */ + private val stalkerVodSearchEmptyCacheTtlMs = 10 * 60_000L + private val maxStalkerVodSearchCacheEntries = 64 + + /** + * Shortest head-of-title that is still worth asking a portal for. Below + * this a subtitle split stops naming a film - "It: Chapter Two" would ask + * for "It" and get a slice of the catalog back. + */ + private val minStalkerVodQueryHeadLength = 3 + /** * Public accessor kept for compatibility with code that previously read the * single cached Stalker API instance. Returns the first cached portal API. @@ -3712,6 +3781,7 @@ class IptvRepository @Inject constructor( cachedEpgAt = 0L stalkerEpgCache.clear() stalkerShortEpgCache.clear() + stalkerVodSearchCache.clear() discoveredM3uEpgUrls.clear() xtreamVodCacheKey = null xtreamVodLoadedAtMs = 0L @@ -3743,7 +3813,7 @@ class IptvRepository @Inject constructor( * way back to the provider. * * Unlike [invalidateCache], this also deletes the disk catalogs so that - * [warmXtreamVodCachesIfPossible] is guaranteed to re-fetch from network. + * [warmVodCachesIfPossible] is guaranteed to re-fetch from network. */ suspend fun purgeAllIptvSourceCaches(preserveLiveSnapshot: Boolean = false) { val sourceKey = currentEpgIndexKey @@ -5292,12 +5362,13 @@ class IptvRepository @Inject constructor( year: Int?, imdbId: String? = null, tmdbId: Int? = null, - allowNetwork: Boolean = true + allowNetwork: Boolean = true, + originalTitle: String? = null ): List { return withContext(Dispatchers.IO) { if (!isVodSearchEnabled()) return@withContext emptyList() val config = observeConfig().first() - xtreamCredentialsForVodImport(config) + val xtreamSources = xtreamCredentialsForVodImport(config) .flatMap { creds -> runCatching { findMovieVodSourcesForCredentials( @@ -5310,7 +5381,23 @@ class IptvRepository @Inject constructor( ) }.getOrDefault(emptyList()) } - .let(::sortVodSources) + // Additive second provider: each Stalker portal is searched on its + // own, and a failing portal never removes Xtream results. + val stalkerSources = activeStalkerPortals(config) + .flatMap { portal -> + runCatching { + findStalkerMovieVodSources( + portal = portal, + title = title, + year = year, + tmdbId = tmdbId, + imdbId = imdbId, + allowNetwork = allowNetwork, + originalTitle = originalTitle + ) + }.getOrDefault(emptyList()) + } + sortVodSources(xtreamSources + stalkerSources) } } @@ -5405,6 +5492,360 @@ class IptvRepository @Inject constructor( return sources } + // ── Stalker VOD (movies) ──────────────────────────────────────────────── + + /** + * Stalker counterpart to [findMovieVodSourcesForCredentials]. + * + * Unlike Xtream, a Stalker portal serves its catalog only in small pages + * (14 entries on a stock Ministra build), so downloading and indexing the + * whole catalog locally would mean hundreds of requests per refresh on a + * large portal - exactly the request pattern that gets users throttled or + * IP-banned by their provider. The portal's own `search` narrows the same + * endpoint server-side instead, and the handful of entries that come back + * is scored with the same TMDB-id / title+year matching the Xtream path + * uses. + */ + private suspend fun findStalkerMovieVodSources( + portal: StalkerPortalEntry, + title: String, + year: Int?, + tmdbId: Int?, + imdbId: String?, + allowNetwork: Boolean, + originalTitle: String? = null + ): List { + if (portal.portalUrl.isBlank() || portal.macAddress.isBlank()) return emptyList() + val fingerprint = stalkerPortalFingerprint(portal) + // Portal id and fingerprint are both part of the key: two portals never + // read each other's matches, and re-pointing a portal at another server + // invalidates only that portal's entries. + val cacheKey = iptvMovieSourceCacheKey( + profileIdHash = profileIdHash(), + imdbId = imdbId, + tmdbId = tmdbId, + title = title, + year = year + )?.let { base -> "stalker|${portal.id}|$base" } + if (cacheKey != null) { + lookupCachedMovieSources(cacheKey, fingerprint)?.let { return it } + } + // Nothing to fall back on offline: there is no local Stalker catalog, + // the cached result above is the only network-free answer. + if (!allowNetwork) return emptyList() + + val normalizedTitle = normalizeLookupText(title) + if (normalizedTitle.isBlank()) return emptyList() + val api = getOrCreateStalkerApi(portal) ?: return emptyList() + + val normalizedTmdb = normalizeTmdbId(tmdbId) + val normalizedOriginalTitle = normalizeLookupText(originalTitle.orEmpty()) + .takeIf { it.isNotBlank() && it != normalizedTitle } + val inputYear = year ?: parseYear(title) + + var matches: List = emptyList() + // Counted separately from the matches: portals that ignore `search` answer + // every query with the head of their whole catalogue, so a high offered + // count next to zero matches names the portal as the cause, whereas both + // at zero points at the request or the portal's catalogue. + var offered = 0 + for (query in stalkerVodSearchQueries(title, originalTitle)) { + val items = stalkerVodSearch(portal, fingerprint, api, query) + offered += items.size + if (items.isEmpty()) continue + matches = matchStalkerVodItems( + items = items, + normalizedTitle = normalizedTitle, + normalizedTmdb = normalizedTmdb, + inputYear = inputYear, + normalizedOriginalTitle = normalizedOriginalTitle + ) + if (matches.isNotEmpty()) break + } + System.err.println( + "[Stalker-VOD] portal=${portal.id} title='$title' " + + "offered=$offered matches=${matches.size}" + ) + if (matches.isEmpty()) return emptyList() + + val sources = sortVodSources( + matches.mapNotNull { item -> + item.toStalkerMovieVodSource(portal, title.ifBlank { normalizedTmdb.orEmpty() }) + } + ) + if (cacheKey != null && sources.isNotEmpty()) { + storeCachedMovieSources(cacheKey, sources, fingerprint) + } + return sources + } + + /** Empty answers expire quickly, real hits keep the long TTL. */ + private fun cacheTtlFor(items: List<*>): Long = + if (items.isEmpty()) stalkerVodSearchEmptyCacheTtlMs else stalkerVodSearchCacheTtlMs + + /** + * The terms one portal lookup may spend, most likely first. The caller + * stops at the first term that produced a match, so the later ones only + * cost a request when the earlier ones found nothing. + * + * A portal matches `search` literally against its own catalog name, and + * that name is not the name TMDB shows the user. Measured against a real + * portal: TMDB says "Der Astronaut - Project Hail Mary" with an en dash, + * the catalog lists "DE - Der Astronaut: Project Hail Mary (2026)" with a + * colon, and the literal search therefore answers with nothing at all - + * while the same film sits in that catalog eleven times under its original + * title. Hence three terms, none of which is enough on its own: + * + * 1. [originalTitle] - most catalog entries are listed under the original + * name, so this is the term that hits first most of the time. + * 2. [title] as displayed - the only term that finds an entry a panel + * carries purely localized: "Die Verurteilten" does not contain + * "The Shawshank Redemption" anywhere. + * 3. The part in front of a subtitle separator - the rescue anchor for + * the punctuation mismatch above, and for panels that list "Dune" + * where TMDB says "Dune: Part Two". + * + * Costs nothing in the common case: when a user browses in the original + * language, terms 1 and 2 are the same string and only one request goes + * out, exactly as before. + */ + internal fun stalkerVodSearchQueries( + title: String, + originalTitle: String? = null + ): List { + val queries = mutableListOf() + fun add(candidate: String) { + val term = candidate.trim() + if (term.isBlank()) return + // Case-insensitive: a portal search is case-insensitive too, so a + // second spelling of the same term would only buy a second + // identical answer. + if (queries.any { it.equals(term, ignoreCase = true) }) return + queries += term + } + + add(originalTitle.orEmpty()) + val primary = title.trim() + add(primary) + + // Derived, not given: only used when it still names the film. Two + // characters ("It: Chapter Two" -> "It") would ask the portal for a + // slice of its whole catalog instead. + val head = primary.subtitleHead() + if (head.length >= minStalkerVodQueryHeadLength) add(head) + + return queries + } + + /** + * Everything in front of the first subtitle separator. + * + * The dashes are spaced on purpose: an unspaced hyphen belongs to names + * like "Spider-Man", and an unspaced en dash to year ranges. The en and em + * dash are in the list because TMDB writes German subtitles with them + * while portals write a colon - the exact mismatch this whole helper is + * about. + */ + private fun String.subtitleHead(): String = + substringBefore(':') + .substringBefore(" - ") + .substringBefore(" – ") + .substringBefore(" — ") + .trim() + + private suspend fun stalkerVodSearch( + portal: StalkerPortalEntry, + fingerprint: String, + api: com.arflix.tv.data.api.StalkerApi, + query: String + ): List { + val term = query.trim() + if (term.isBlank()) return emptyList() + val key = StalkerVodSearchCacheKey(portal.id, fingerprint, term.lowercase(Locale.US)) + val now = System.currentTimeMillis() + stalkerVodSearchCache[key]?.let { cached -> + if (now - cached.fetchedAtMs < cacheTtlFor(cached.items)) return cached.items + stalkerVodSearchCache.remove(key) + } + // null means the request itself failed. Caching that would turn one + // bad moment into hours of "this portal has no such film". + val items = api.searchVod(term) ?: return emptyList() + if (stalkerVodSearchCache.size >= maxStalkerVodSearchCacheEntries) { + // Bounded on purpose: one answer is small, but a long browsing + // session must not accumulate an entry per looked-up movie. + stalkerVodSearchCache.clear() + } + stalkerVodSearchCache[key] = StalkerVodSearchCacheEntry(now, items) + return items + } + + /** Movie entries of a portal search, scored by [matchStalkerCatalogEntries]. */ + internal fun matchStalkerVodItems( + items: List, + normalizedTitle: String, + normalizedTmdb: String?, + inputYear: Int?, + normalizedOriginalTitle: String? = null + ): List = matchStalkerCatalogEntries( + items = items, + normalizedTitle = normalizedTitle, + normalizedTmdb = normalizedTmdb, + inputYear = inputYear, + normalizedOriginalTitle = normalizedOriginalTitle + ) { StalkerCatalogFields(it.name, it.cmd, it.year, it.tmdbId) } + + /** The fields a Stalker catalog entry is scored on. */ + private data class StalkerCatalogFields( + val name: String?, + val cmd: String?, + val year: String?, + val tmdbId: String? + ) + + /** + * Scores portal entries against a wanted title. Written over the entry's + * fields rather than over one item type: `get_ordered_list` answers with + * the same four fields for every catalog it serves. + * + * Two stages, as on the Xtream path: a portal-supplied `tmdb_id` wins + * outright, otherwise entries are scored on their title with the existing + * [scoreNameMatch] plus the year bonus/penalty and score window + * [findMovieCandidatesIndexed] applies. Entries without a `cmd` are dropped + * either way - there would be nothing to play. + * + * [normalizedOriginalTitle] is scored as an equal alternative, not as a + * fallback: [stalkerVodSearchQueries] asks the portal for the original + * title as well, and a catalog listing the film only under that name - + * "EN - Project Hail Mary (2026)" for a user browsing in German - would + * otherwise be found and then thrown away. Both names denote the same + * film, so the better of the two scores is the entry's score. Portals that + * supply a `tmdb_id` never reach this stage. + */ + private fun matchStalkerCatalogEntries( + items: List, + normalizedTitle: String, + normalizedTmdb: String?, + inputYear: Int?, + normalizedOriginalTitle: String? = null, + fields: (T) -> StalkerCatalogFields + ): List { + if (items.isEmpty()) return emptyList() + if (!normalizedTmdb.isNullOrBlank()) { + val idMatches = items.filter { normalizeTmdbId(fields(it).tmdbId) == normalizedTmdb } + if (idMatches.isNotEmpty()) return idMatches + } + val wantedNames = listOfNotNull( + normalizedTitle.takeIf { it.isNotBlank() }, + normalizedOriginalTitle?.takeIf { it.isNotBlank() } + ).distinct() + if (wantedNames.isEmpty()) return emptyList() + + val scored = items + .mapNotNull { item -> + val entry = fields(item) + val itemName = entry.name?.trim().orEmpty() + if (itemName.isBlank()) return@mapNotNull null + if (entry.cmd.isNullOrBlank()) return@mapNotNull null + val score = wantedNames.maxOf { scoreNameMatch(itemName, it) } + if (score <= 0) return@mapNotNull null + val providerYear = parseYear(entry.year?.trim().orEmpty().ifBlank { itemName }) + val yearDelta = if (inputYear != null && providerYear != null) { + kotlin.math.abs(providerYear - inputYear) + } else null + val yearAdjust = when { + yearDelta == null -> 0 + yearDelta == 0 -> 20 + yearDelta == 1 -> 8 + else -> -25 + } + item to (score + yearAdjust) + } + .sortedByDescending { it.second } + val bestScore = scored.firstOrNull()?.second ?: return emptyList() + val minScore = maxOf(65, bestScore - 8) + return scored.takeWhile { it.second >= minScore }.map { it.first } + } + + private fun com.arflix.tv.data.api.StalkerApi.StalkerVodItem.toStalkerMovieVodSource( + portal: StalkerPortalEntry, + fallbackTitle: String + ): StreamSource? { + val marker = StalkerVodLink.buildMarker(portal.id, cmd.orEmpty()) ?: return null + val sourceName = name?.trim().orEmpty().ifBlank { fallbackTitle } + return StreamSource( + source = sourceName, + addonName = "IPTV VOD", + addonId = IptvVodSourceIds.STALKER, + quality = stalkerVodQuality(sourceName, hd), + size = "", + url = marker, + description = stalkerVodDescription(portal, time, ratingImdb) + ) + } + + /** + * Stalker knows no resolution field - the portal only flags `hd` - so the + * title is still the better source when it names one. Falling back to the + * flag at least separates HD entries from the rest. + */ + private fun stalkerVodQuality(sourceName: String, hdFlag: String?): String { + val inferred = inferQuality(sourceName) + if (inferred != "VOD") return inferred + return if (hdFlag?.trim() == "1") "HD" else "VOD" + } + + /** + * The little the portal knows beyond the title, which is what makes two + * entries of the same movie tellable apart: which portal it came from, how + * long it runs, and its IMDb rating. + */ + private fun stalkerVodDescription( + portal: StalkerPortalEntry, + runtime: String?, + ratingImdb: String? + ): String? { + val parts = mutableListOf() + portal.name.trim().takeIf { it.isNotBlank() }?.let(parts::add) + runtime?.trim()?.takeIf { it.isNotBlank() }?.let { value -> + val minutes = value.toIntOrNull() + parts += if (minutes != null && minutes > 0) "$minutes min" else value + } + ratingImdb?.trim()?.toDoubleOrNull()?.takeIf { it > 0.0 }?.let { parts += "IMDb $it" } + return parts.joinToString(" \u00b7 ").ifBlank { null } + } + + /** + * Stable, secret-free identity of a portal. Used as the cache fingerprint, + * so a portal that gets re-pointed at another server or MAC drops its own + * cached matches without touching any other source. + */ + private fun stalkerPortalFingerprint(portal: StalkerPortalEntry): String { + val raw = "${portal.portalUrl.trim().trimEnd('/').lowercase(Locale.ROOT)}|" + + portal.macAddress.trim().uppercase(Locale.ROOT) + return MessageDigest.getInstance("MD5").digest(raw.toByteArray(StandardCharsets.UTF_8)) + .joinToString("") { "%02x".format(it) } + } + + /** + * Turns the `stalker_vod://` placeholder of a matched movie into a playable + * URL. Called from [StreamRepository.resolveStreamForPlayback] the moment + * playback starts - never while a source list is being built. + */ + suspend fun resolveStalkerVodStreamUrl(markerUrl: String): String? { + val (portalId, command) = StalkerVodLink.parseMarker(markerUrl) ?: return null + val config = observeConfig().first() + // No "fall back to the first portal" here: the marker always carries the + // portal it came from, and guessing would resolve against a stranger. + val portal = config.stalkerPortals.firstOrNull { it.id == portalId } ?: return null + if (portal.portalUrl.isBlank() || portal.macAddress.isBlank()) return null + val api = getOrCreateStalkerApi(portal) ?: return null + val resolved = api.resolveVodStreamUrl(command) + System.err.println( + "[Stalker-VOD] create_link portal=$portalId resolved=${!resolved.isNullOrBlank()}" + ) + return resolved + } + suspend fun findEpisodeVodSource( title: String, season: Int, @@ -5477,7 +5918,7 @@ class IptvRepository @Inject constructor( StreamSource( source = sourceName, addonName = "IPTV Series VOD", - addonId = "iptv_xtream_vod", + addonId = IptvVodSourceIds.XTREAM, quality = inferQuality(sourceName), size = "", url = streamUrl @@ -5643,7 +6084,7 @@ class IptvRepository @Inject constructor( return StreamSource( source = sourceName, addonName = "IPTV VOD", - addonId = "iptv_xtream_vod", + addonId = IptvVodSourceIds.XTREAM, quality = inferQuality(sourceName), size = "", url = streamUrl @@ -5661,7 +6102,7 @@ class IptvRepository @Inject constructor( return StreamSource( source = sourceName, addonName = "IPTV Episode VOD", - addonId = "iptv_xtream_vod", + addonId = IptvVodSourceIds.XTREAM, quality = inferQuality(sourceName), size = "", url = streamUrl @@ -5679,10 +6120,24 @@ class IptvRepository @Inject constructor( ) } - suspend fun warmXtreamVodCachesIfPossible() { + /** + * Background pre-warm for every configured VOD provider, called from the + * home, TV and settings screens so the first movie lookup after start-up is + * not the one that pays for the cold caches. + * + * Stalker has no catalog to pre-download - its searches run server-side - + * but the portal handshake does probe up to five base paths before the + * first request succeeds, so that is what gets warmed here. A new source + * that skips this path still works, it just silently loses the head start + * this function exists for. + */ + suspend fun warmVodCachesIfPossible() { withContext(Dispatchers.IO) { if (!isVodSearchEnabled()) return@withContext val config = observeConfig().first() + activeStalkerPortals(config).forEach { portal -> + runCatching { getOrCreateStalkerApi(portal) } + } xtreamCredentialsForVodImport(config).forEach { creds -> runCatching { loadXtreamVodStreams(creds) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index 85b8271d6..0cac4c98d 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -3974,6 +3974,8 @@ private fun TmdbMediaItem.toMediaItem(defaultType: MediaType): MediaItem { backdrop = backdropPath?.let { "${Constants.BACKDROP_BASE_LARGE}$it" }, genreIds = genreIds, originalLanguage = originalLanguage, + originalTitle = originalTitle?.takeIf { it.isNotBlank() } + ?: originalName?.takeIf { it.isNotBlank() }, character = character ?: "", popularity = popularity ) @@ -4004,6 +4006,7 @@ private fun TmdbMovieDetails.toMediaItem(): MediaItem { ?: "", backdrop = backdropPath?.let { "${Constants.BACKDROP_BASE_LARGE}$it" }, originalLanguage = originalLanguage, + originalTitle = originalTitle?.takeIf { it.isNotBlank() }, budget = budget, genreIds = genres.map { it.id } ) @@ -4040,6 +4043,7 @@ private fun TmdbTvDetails.toMediaItem(): MediaItem { ?: "", backdrop = backdropPath?.let { "${Constants.BACKDROP_BASE_LARGE}$it" }, originalLanguage = originalLanguage, + originalTitle = originalName?.takeIf { it.isNotBlank() }, isOngoing = status == "Returning Series", totalEpisodes = actualSeasonCount, status = status, diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index c2324675b..22b21e3f3 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -23,6 +23,8 @@ import com.arflix.tv.data.model.SportsAddonCapabilities import com.arflix.tv.data.telegram.TelegramSourceResolver import com.arflix.tv.data.model.ProxyHeaders as ModelProxyHeaders import com.arflix.tv.data.model.StreamBehaviorHints as ModelStreamBehaviorHints +import com.arflix.tv.data.model.IptvVodSourceIds +import com.arflix.tv.data.model.StalkerVodLink import com.arflix.tv.data.model.StreamSource import com.arflix.tv.data.model.Subtitle import com.arflix.tv.network.OkHttpProvider @@ -1757,7 +1759,7 @@ class StreamRepository @Inject constructor( val url = stream.url?.trim().orEmpty() return when { addonId == HomeServerRepository.ADDON_ID -> "home_server" - addonId == "iptv_xtream_vod" -> "iptv_vod" + IptvVodSourceIds.isIptvVodAddonId(addonId) -> "iptv_vod" url.startsWith("magnet:", ignoreCase = true) || !stream.infoHash.isNullOrBlank() -> "p2p" url.startsWith("http://", ignoreCase = true) || url.startsWith("https://", ignoreCase = true) -> "http" else -> "unknown" @@ -2452,7 +2454,8 @@ class StreamRepository @Inject constructor( title: String = "", year: Int? = null, tmdbId: Int? = null, - timeoutMs: Long = 15_000L + timeoutMs: Long = 15_000L, + originalTitle: String? = null ): List = withContext(Dispatchers.IO) { withTimeoutOrNull(timeoutMs.coerceIn(500L, 90_000L)) { runCatching { @@ -2461,7 +2464,8 @@ class StreamRepository @Inject constructor( year = year, imdbId = imdbId, tmdbId = tmdbId, - allowNetwork = true + allowNetwork = true, + originalTitle = originalTitle ) }.onFailure { e -> System.err.println("[VOD] resolveMovieVodSources failed: ${e.message}") @@ -3658,6 +3662,19 @@ class StreamRepository @Inject constructor( // Debrid/direct-only playback path: ignore magnet/infoHash-only P2P streams. if (url.startsWith("magnet:", ignoreCase = true)) return null + // Stalker VOD sources carry a `stalker_vod://` placeholder instead of a + // URL: the portal only issues a playable link on demand, so it is + // exchanged here - once per source, at playback time - rather than while + // the source list is built. A null result marks the source unresolvable + // and the caller fails over to the next one. + if (StalkerVodLink.isMarker(url)) { + val direct = iptvRepository.resolveStalkerVodStreamUrl(url)?.trim().orEmpty() + val playable = direct.startsWith("http://", ignoreCase = true) || + direct.startsWith("https://", ignoreCase = true) + if (!playable) return null + return stream.copy(url = direct) + } + val normalizedUrl = when { url.startsWith("http://", ignoreCase = true) || url.startsWith("https://", ignoreCase = true) -> url url.startsWith("//") -> "https:$url" diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt index 1d0d80c79..96cc53608 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt @@ -89,6 +89,8 @@ import androidx.tv.foundation.lazy.list.TvLazyListState import androidx.tv.foundation.lazy.list.rememberTvLazyListState import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text +import com.arflix.tv.data.model.IptvVodSourceIds +import com.arflix.tv.data.model.isDirectStreamUrl import com.arflix.tv.data.model.StreamSource import com.arflix.tv.ui.focus.arvioDpadFocusGroup import com.arflix.tv.ui.theme.ArflixTypography @@ -1334,8 +1336,8 @@ private fun presentSource(stream: StreamSource, unknownSourceLabel: String): Sou addonLower.contains("alldebrid") || searchBlob.contains("magnet:", ignoreCase = true) - val hasDirectHttpUrl = !stream.url.isNullOrBlank() && stream.url.startsWith("http", true) - val isIptvVod = stream.addonId == "iptv_xtream_vod" || addonLower.contains("iptv vod") + val hasDirectHttpUrl = isDirectStreamUrl(stream.url) + val isIptvVod = IptvVodSourceIds.isIptvVodAddonId(stream.addonId) || addonLower.contains("iptv vod") val isDebridReady = isDebridLikeSource(stream, searchBlob) val isReady = stream.behaviorHints?.cached == true || isDebridReady @@ -1399,7 +1401,7 @@ private fun presentSource(stream: StreamSource, unknownSourceLabel: String): Sou qualityColor = qualityColor, sizeBytes = getSizeBytes(stream), sortCached = isReady, - sortDirect = !stream.url.isNullOrBlank() && stream.url.startsWith("http", true), + sortDirect = isDirectStreamUrl(stream.url), description = cleanStreamDescription(stream.description, rawTitle), bitrateLabel = StreamRegexes.BITRATE.find(stream.description.orEmpty()) ?.let { "${it.groupValues[1]} Mbps" }, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/AutoPlaySourcePlanner.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/AutoPlaySourcePlanner.kt index 782860e2a..da73102b5 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/AutoPlaySourcePlanner.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/AutoPlaySourcePlanner.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.screens.details +import com.arflix.tv.data.model.isDirectStreamUrl import com.arflix.tv.data.model.StreamSource import java.util.Locale @@ -98,7 +99,10 @@ internal fun minQualityThreshold(value: String): Int { internal fun isAutoPlayableStream(stream: StreamSource): Boolean { val url = stream.url?.trim().orEmpty() - if (!url.startsWith("http", ignoreCase = true)) return false + // A Stalker VOD source carries a placeholder that only turns into an http + // URL when playback starts. It is autoplayable all the same - resolving it + // any earlier would cost the portal a link per candidate. + if (!isDirectStreamUrl(url)) return false return !isPendingDebridStream(stream) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index f89f75707..0e3ebee03 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -15,6 +15,7 @@ import com.arflix.tv.data.model.MediaType import com.arflix.tv.data.model.PersonDetails import com.arflix.tv.data.model.Review import com.arflix.tv.data.model.SportsAddonCapabilities +import com.arflix.tv.data.model.IptvVodSourceIds import com.arflix.tv.data.model.StreamSource import com.arflix.tv.data.model.Subtitle import com.arflix.tv.data.api.TmdbApi @@ -189,7 +190,7 @@ enum class ToastType { } private fun isSupplementalStream(stream: StreamSource): Boolean = - stream.addonId == "iptv_xtream_vod" || stream.addonId == HomeServerRepository.ADDON_ID + IptvVodSourceIds.isIptvVodAddonId(stream.addonId) || stream.addonId == HomeServerRepository.ADDON_ID private fun Addon.isVodStreamingAddon(): Boolean = isEnabled && @@ -353,6 +354,7 @@ class DetailsViewModel @Inject constructor( primaryNetworkLogo = primary.primaryNetworkLogo ?: fallback.primaryNetworkLogo, genreIds = if (primary.genreIds.isEmpty()) fallback.genreIds else primary.genreIds, originalLanguage = primary.originalLanguage ?: fallback.originalLanguage, + originalTitle = primary.originalTitle ?: fallback.originalTitle, isOngoing = primary.isOngoing || fallback.isOngoing, totalEpisodes = primary.totalEpisodes ?: fallback.totalEpisodes, watchedEpisodes = primary.watchedEpisodes ?: fallback.watchedEpisodes, @@ -3047,6 +3049,9 @@ class DetailsViewModel @Inject constructor( return } val itemTitle = _uiState.value.item?.title.orEmpty() + // Passed alongside the displayed title: a provider catalogue may list + // the title only under its original name. + val itemOriginalTitle = _uiState.value.item?.originalTitle val vodSources = if (requestMediaType == MediaType.MOVIE) { streamRepository.resolveMovieVodSources( @@ -3054,7 +3059,8 @@ class DetailsViewModel @Inject constructor( title = itemTitle, year = _uiState.value.item?.year?.toIntOrNull(), tmdbId = currentMediaId, - timeoutMs = timeoutMs + timeoutMs = timeoutMs, + originalTitle = itemOriginalTitle ) } else { streamRepository.resolveEpisodeVodSources( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 580a20e65..deb3ec4aa 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -1977,10 +1977,10 @@ class HomeViewModel @Inject constructor( delay(if (isLowRamDevice) 10 * 60_000L else 8 * 60_000L) kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { try { - iptvRepository.warmXtreamVodCachesIfPossible() + iptvRepository.warmVodCachesIfPossible() } catch (e: Exception) { if (e is CancellationException) throw e - AppLogger.e("HomeVM", "warmXtreamVodCachesIfPossible failed", e) + AppLogger.e("HomeVM", "warmVodCachesIfPossible failed", e) } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 7547188ef..c4a66b8c6 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt @@ -16,6 +16,8 @@ import com.arflix.tv.data.model.AddonType import com.arflix.tv.data.model.MediaType import com.arflix.tv.data.model.EpisodeIdentity import com.arflix.tv.data.model.SportsAddonCapabilities +import com.arflix.tv.data.model.IptvVodSourceIds +import com.arflix.tv.data.model.isDirectStreamUrl import com.arflix.tv.data.model.StreamSource import com.arflix.tv.data.model.Subtitle import com.arflix.tv.data.repository.MediaRepository @@ -79,7 +81,7 @@ import com.arflix.tv.ui.screens.player.common.PlaybackEpisodeKey import javax.inject.Inject private fun isSupplementalStream(stream: StreamSource): Boolean = - stream.addonId == "iptv_xtream_vod" || stream.addonId == HomeServerRepository.ADDON_ID + IptvVodSourceIds.isIptvVodAddonId(stream.addonId) || stream.addonId == HomeServerRepository.ADDON_ID private fun Addon.isVodStreamingAddon(): Boolean = isEnabled && @@ -529,7 +531,7 @@ class PlayerViewModel @Inject constructor( val url = source.url?.trim().orEmpty() return when { addonId == HomeServerRepository.ADDON_ID -> "home_server" - addonId == "iptv_xtream_vod" -> "iptv_vod" + IptvVodSourceIds.isIptvVodAddonId(addonId) -> "iptv_vod" url.startsWith("magnet:", ignoreCase = true) || !source.infoHash.isNullOrBlank() -> "p2p" url.startsWith("http://", ignoreCase = true) || url.startsWith("https://", ignoreCase = true) -> "http" else -> "unknown" @@ -1984,7 +1986,7 @@ class PlayerViewModel @Inject constructor( if (text.contains("x264") || text.contains("h264")) score += 20 if (stream.behaviorHints?.cached == true || text.contains(" rd+")) score += 500 if (stream.behaviorHints?.notWebReady == true) score -= 150 - if (!stream.url.isNullOrBlank() && stream.url.startsWith("http", ignoreCase = true)) score += 100 + if (isDirectStreamUrl(stream.url)) score += 100 if (stream.url?.startsWith("magnet:", ignoreCase = true) == true) score -= 800 score += streamRepository.getAddonHealthBias(stream.addonId) @@ -5037,6 +5039,9 @@ class PlayerViewModel @Inject constructor( val lookupTitle = currentItemTitle .ifBlank { currentTitle } .ifBlank { mediaRepository.getCachedItem(mediaType, currentMediaId)?.title.orEmpty() } + // Passed alongside the displayed title: a provider catalogue may list + // the title only under its original name. + val lookupOriginalTitle = mediaRepository.getCachedItem(mediaType, currentMediaId)?.originalTitle val vodSources = if (mediaType == MediaType.MOVIE) { streamRepository.resolveMovieVodSources( @@ -5044,7 +5049,8 @@ class PlayerViewModel @Inject constructor( title = lookupTitle, year = null, tmdbId = currentMediaId, - timeoutMs = timeoutMs + timeoutMs = timeoutMs, + originalTitle = lookupOriginalTitle ) } else { streamRepository.resolveEpisodeVodSources( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 4544b30c3..976b02980 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -2843,7 +2843,7 @@ class SettingsViewModel @Inject constructor( toastType = if (showToast) ToastType.SUCCESS else _uiState.value.toastType ) launch { - runCatching { iptvRepository.warmXtreamVodCachesIfPossible() } + runCatching { iptvRepository.warmVodCachesIfPossible() } } }.onFailure { error -> if (error is CancellationException) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt index bf95f73d8..5363b218a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvViewModel.kt @@ -584,7 +584,7 @@ class TvViewModel @Inject constructor( private fun warmXtreamVodCache() { if (warmVodJob?.isActive == true) return warmVodJob = viewModelScope.launch(Dispatchers.IO) { - try { iptvRepository.warmXtreamVodCachesIfPossible() } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e } + try { iptvRepository.warmVodCachesIfPossible() } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e } }.also { job -> job.invokeOnCompletion { warmVodJob = null } } diff --git a/app/src/test/kotlin/com/arflix/tv/data/api/StalkerApiTest.kt b/app/src/test/kotlin/com/arflix/tv/data/api/StalkerApiTest.kt index 7f8008a3b..d41b40a3f 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/api/StalkerApiTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/api/StalkerApiTest.kt @@ -710,4 +710,212 @@ class StalkerApiTest { assertNull(api.resolveStreamUrl("ffmpeg http://host/ch/9_")) } + + // ── VOD ─────────────────────────────────────────────────────────────── + + @Test + fun `searchVod asks the portal instead of walking the catalog`() = runTest { + val requests = mutableListOf() + val api = stubApi(requests = requests) { url -> + when { + url.contains("action=get_ordered_list") -> """ + {"js":{"total_items":1,"max_page_items":14,"data":[ + {"id":"42","name":"Dune (2021)","cmd":"/media/dune.mpg","year":"2021","tmdb_id":"438631"} + ]}} + """.trimIndent() + else -> null + } + } + + val items = api.searchVod("Dune")!! + + assertEquals(1, items.size) + assertEquals("Dune (2021)", items.first().name) + assertEquals("438631", items.first().tmdbId) + assertEquals(1, requests.size) + assertTrue(requests.single().contains("type=vod&action=get_ordered_list")) + assertTrue(requests.single().contains("search=Dune")) + assertTrue(requests.single().contains("category=0")) + // Matching must never cost a link: create_link happens at playback only. + assertTrue(requests.none { it.contains("action=create_link") }) + } + + @Test + fun `searchVod asks every category and lets the portal sort by name`() = runTest { + // Measured against a working portal: a full client asks + // category=0&sortby=name and gets its matches. category=* is the + // category list's word for "all" and get_ordered_list does not take it; + // sortby=added buries a match behind everything added since. + val requests = mutableListOf() + val api = stubApi(requests = requests) { url -> + when { + url.contains("action=get_ordered_list") -> """ + {"js":{"total_items":1,"max_page_items":14,"data":[ + {"id":"42","name":"Dune (2021)","cmd":"/media/dune.mpg"} + ]}} + """.trimIndent() + else -> null + } + } + + api.searchVod("Dune") + + val url = requests.single() + assertTrue(url.contains("&category=0&")) + assertTrue(url.contains("&sortby=name&")) + assertFalse(url.contains("category=*")) + assertFalse(url.contains("sortby=added")) + } + + @Test + fun `searchVod never asks for more pages than its cap allows`() = runTest { + // A portal that reports a total far beyond what we page for must not + // pull the whole catalogue down: the cap is what keeps a search a + // search. Sorted by name, the matches for one term stay inside it. + val requests = mutableListOf() + val api = stubApi(requests = requests) { url -> + val page = Regex("&p=(\\d+)").find(url)?.groupValues?.get(1) ?: "1" + when { + url.contains("action=get_ordered_list") -> """ + {"js":{"total_items":104021,"max_page_items":14,"data":[ + {"id":"$page","name":"Hulk $page","cmd":"/media/hulk$page.mpg"} + ]}} + """.trimIndent() + else -> null + } + } + + api.searchVod("Hulk") + + assertEquals(StalkerApi.DEFAULT_VOD_SEARCH_PAGES, requests.size) + assertTrue(requests.any { it.contains("&p=1&") }) + assertTrue(requests.none { it.contains("&p=${StalkerApi.DEFAULT_VOD_SEARCH_PAGES + 1}&") }) + } + + @Test + fun `searchVod pages until the reported total is covered`() = runTest { + val requests = mutableListOf() + val api = stubApi(requests = requests) { url -> + when { + url.contains("&p=1") -> """ + {"js":{"total_items":3,"max_page_items":2,"data":[ + {"id":"1","name":"Alien","cmd":"/a.mpg"}, + {"id":"2","name":"Aliens","cmd":"/b.mpg"} + ]}} + """.trimIndent() + url.contains("&p=2") -> """ + {"js":{"total_items":3,"max_page_items":2,"data":[ + {"id":"3","name":"Alien 3","cmd":"/c.mpg"} + ]}} + """.trimIndent() + else -> null + } + } + + val items = api.searchVod("Alien")!! + + assertEquals(listOf("Alien", "Aliens", "Alien 3"), items.map { it.name }) + assertEquals(2, requests.size) + } + + @Test + fun `searchVod stops when a portal ignores paging and repeats itself`() = runTest { + val requests = mutableListOf() + val api = stubApi(requests = requests) { url -> + if (url.contains("action=get_ordered_list")) { + """ + {"js":{"total_items":999,"max_page_items":1,"data":[ + {"id":"7","name":"Heat","cmd":"/heat.mpg"} + ]}} + """.trimIndent() + } else { + null + } + } + + val items = api.searchVod("Heat")!! + + assertEquals(1, items.size) + // Page 2 repeats page 1 - no new ids means stop, not 999 requests. + assertEquals(2, requests.size) + } + + @Test + fun `searchVod reports an HTML 200 answer as a failure, not as no results`() = runTest { + val requests = mutableListOf() + val api = stubApi(requests = requests) { "Not found" } + + // null, not emptyList: the caller caches answers, and a broken reply + // cached as "no such film" hides the title until the entry expires. + assertNull(api.searchVod("Dune")) + } + + @Test + fun `searchVod skips entries without a playable cmd`() = runTest { + val api = stubApi(requests = mutableListOf()) { + """ + {"js":{"total_items":2,"max_page_items":14,"data":[ + {"id":"1","name":"No Command"}, + {"id":"2","name":"Playable","cmd":"/ok.mpg"} + ]}} + """.trimIndent() + } + + assertEquals(listOf("Playable"), api.searchVod("x")!!.map { it.name }) + } + + @Test + fun `searchVod ignores a blank query without touching the portal`() = runTest { + val requests = mutableListOf() + val api = stubApi(requests = requests) { null } + + assertTrue(api.searchVod(" ")!!.isEmpty()) + assertTrue(requests.isEmpty()) + } + + @Test + fun `resolveVodStreamUrl exchanges the cmd for a playable url`() = runTest { + val requests = mutableListOf() + val api = stubApi(requests = requests) { url -> + when { + url.contains("type=vod&action=create_link") -> + """{"js":{"cmd":"ffmpeg http://cdn.example.com/movie.mp4"}}""" + else -> null + } + } + + val url = api.resolveVodStreamUrl("/media/file_1.mpg") + + assertEquals("http://cdn.example.com/movie.mp4", url) + assertTrue(requests.single().contains("cmd=%2Fmedia%2Ffile_1.mpg")) + } + + @Test + fun `resolveVodStreamUrl returns null when the portal answers without a link`() = runTest { + val api = stubApi(requests = mutableListOf()) { """{"js":{"cmd":""}}""" } + + assertNull(api.resolveVodStreamUrl("/media/file.mpg")) + } + + @Test + fun `sanitizePlaybackCommand strips the player hint but keeps bare urls`() { + assertEquals( + "http://cdn.example.com/a.mp4", + StalkerApi.sanitizePlaybackCommand("ffmpeg http://cdn.example.com/a.mp4") + ) + assertEquals( + "http://cdn.example.com/a.mp4", + StalkerApi.sanitizePlaybackCommand("auto http://cdn.example.com/a.mp4") + ) + assertEquals( + "http://cdn.example.com/a.mp4", + StalkerApi.sanitizePlaybackCommand(" http://cdn.example.com/a.mp4 ") + ) + assertNull(StalkerApi.sanitizePlaybackCommand("")) + assertNull(StalkerApi.sanitizePlaybackCommand(null)) + assertNull(StalkerApi.sanitizePlaybackCommand(" ")) + // A lone token carries no hint to strip and is returned unchanged; the + // caller drops it because it is not an http(s) URL. + assertEquals("ffmpeg", StalkerApi.sanitizePlaybackCommand("ffmpeg ")) + } } diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryStalkerVodTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryStalkerVodTest.kt new file mode 100644 index 000000000..46a29bd71 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryStalkerVodTest.kt @@ -0,0 +1,340 @@ +package com.arflix.tv.data.repository + +import com.arflix.tv.data.api.StalkerApi +import com.arflix.tv.data.model.StalkerVodLink +import com.arflix.tv.data.model.isDirectStreamUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Tests for the Stalker VOD movie matching on [IptvRepository]. The matching + * helpers are `internal` so tests can call them directly, the same convention + * the Stalker EPG helpers already follow. + */ +class IptvRepositoryStalkerVodTest { + + private fun newRepository(): IptvRepository { + val context = io.mockk.mockk(relaxed = true) + val okHttpClient = io.mockk.mockk(relaxed = true) + val profileManager = io.mockk.mockk(relaxed = true) + val invalidationBus = io.mockk.mockk(relaxed = true) + return IptvRepository(context, okHttpClient, profileManager, invalidationBus) + } + + private fun item( + id: String, + name: String, + cmd: String = "/media/$id.mpg", + year: String? = null, + tmdbId: String? = null + ) = StalkerApi.StalkerVodItem(id = id, name = name, cmd = cmd, year = year, tmdbId = tmdbId) + + // ── ID matching ─────────────────────────────────────────────────────── + + @Test + fun `a portal supplied tmdb id wins over every title score`() { + val repository = newRepository() + val items = listOf( + item("1", "Dune", year = "2021", tmdbId = "438631"), + item("2", "Dune", year = "1984", tmdbId = "841") + ) + + val matches = repository.matchStalkerVodItems( + items = items, + normalizedTitle = "dune", + normalizedTmdb = "438631", + inputYear = 2021 + ) + + assertEquals(listOf("1"), matches.map { it.id }) + } + + @Test + fun `an unmatched tmdb id falls through to title scoring`() { + val repository = newRepository() + val items = listOf(item("1", "Dune", year = "2021")) + + val matches = repository.matchStalkerVodItems( + items = items, + normalizedTitle = "dune", + normalizedTmdb = "438631", + inputYear = 2021 + ) + + assertEquals(listOf("1"), matches.map { it.id }) + } + + // ── Title + year fallback ───────────────────────────────────────────── + + @Test + fun `title and year fallback prefers the matching year`() { + val repository = newRepository() + val items = listOf( + item("old", "Dune", year = "1984"), + item("new", "Dune", year = "2021") + ) + + val matches = repository.matchStalkerVodItems( + items = items, + normalizedTitle = "dune", + normalizedTmdb = null, + inputYear = 2021 + ) + + assertEquals(listOf("new"), matches.map { it.id }) + } + + @Test + fun `a language prefixed portal title still matches the tmdb title`() { + val repository = newRepository() + val items = listOf(item("1", "DE: Der Herr der Ringe", year = "2001")) + + val matches = repository.matchStalkerVodItems( + items = items, + normalizedTitle = IptvTitleNormalizer.normalize("Der Herr der Ringe"), + normalizedTmdb = null, + inputYear = 2001 + ) + + assertEquals(listOf("1"), matches.map { it.id }) + } + + @Test + fun `unrelated portal results are dropped instead of guessed`() { + val repository = newRepository() + val items = listOf( + item("1", "Completely Different Show"), + item("2", "Another Unrelated Title") + ) + + val matches = repository.matchStalkerVodItems( + items = items, + normalizedTitle = "dune", + normalizedTmdb = null, + inputYear = 2021 + ) + + assertTrue(matches.isEmpty()) + } + + @Test + fun `entries without a playable cmd never become a source`() { + val repository = newRepository() + val items = listOf(StalkerApi.StalkerVodItem(id = "1", name = "Dune", cmd = null, year = "2021")) + + val matches = repository.matchStalkerVodItems( + items = items, + normalizedTitle = "dune", + normalizedTmdb = null, + inputYear = 2021 + ) + + assertTrue(matches.isEmpty()) + } + + @Test + fun `an empty portal answer yields no matches`() { + val repository = newRepository() + + assertTrue( + repository.matchStalkerVodItems( + items = emptyList(), + normalizedTitle = "dune", + normalizedTmdb = "438631", + inputYear = 2021 + ).isEmpty() + ) + } + + // ── Query planning ──────────────────────────────────────────────────── + + @Test + fun `a subtitled title gets one extra fallback query`() { + val repository = newRepository() + + assertEquals( + listOf("Dune: Part Two", "Dune"), + repository.stalkerVodSearchQueries("Dune: Part Two") + ) + assertEquals( + listOf("Mission: Impossible - Dead Reckoning", "Mission"), + repository.stalkerVodSearchQueries("Mission: Impossible - Dead Reckoning") + ) + } + + @Test + fun `a plain title stays a single query`() { + val repository = newRepository() + + assertEquals(listOf("Heat"), repository.stalkerVodSearchQueries("Heat")) + assertTrue(repository.stalkerVodSearchQueries(" ").isEmpty()) + } + + @Test + fun `a too short head is not used as a fallback query`() { + val repository = newRepository() + + assertEquals(listOf("It: Chapter Two"), repository.stalkerVodSearchQueries("It: Chapter Two")) + } + + // ── Query planning: the original title (10.09.2026) ─────────────────── + // + // A portal matches `search` literally against its own catalogue name, and + // that name is not the name TMDB shows the user. Measured against a real + // portal: TMDB writes "Der Astronaut – Project Hail Mary" with an en dash, + // the catalogue lists "DE - Der Astronaut: Project Hail Mary (2026)" with a + // colon, and the search therefore answered with nothing at all. + + @Test + fun `the original title leads the term list and dash separators are understood`() { + val repository = newRepository() + + assertEquals( + listOf( + "Project Hail Mary", + "Der Astronaut – Project Hail Mary", + "Der Astronaut" + ), + repository.stalkerVodSearchQueries( + title = "Der Astronaut – Project Hail Mary", + originalTitle = "Project Hail Mary" + ) + ) + } + + @Test + fun `an em dash subtitle is split like a colon`() { + val repository = newRepository() + + assertEquals( + listOf("Wolfsblut — Ruf der Wildnis", "Wolfsblut"), + repository.stalkerVodSearchQueries("Wolfsblut — Ruf der Wildnis") + ) + } + + @Test + fun `a purely localized title keeps both names as terms`() { + val repository = newRepository() + + // Neither term can be dropped: the original never appears inside the + // German name, and a catalogue may carry either one alone. + assertEquals( + listOf("The Shawshank Redemption", "Die Verurteilten"), + repository.stalkerVodSearchQueries( + title = "Die Verurteilten", + originalTitle = "The Shawshank Redemption" + ) + ) + } + + @Test + fun `a title that is its own original still costs a single query`() { + val repository = newRepository() + + // The common case must not become more expensive than before. + assertEquals( + listOf("Heat"), + repository.stalkerVodSearchQueries(title = "Heat", originalTitle = "Heat") + ) + // Spelling alone must not buy a second, identical portal request - + // a portal search is case-insensitive too. + assertEquals( + listOf("HEAT"), + repository.stalkerVodSearchQueries(title = "heat", originalTitle = "HEAT") + ) + } + + @Test + fun `an original title alone is still worth asking for`() { + val repository = newRepository() + + assertEquals( + listOf("Heat"), + repository.stalkerVodSearchQueries(title = " ", originalTitle = "Heat") + ) + assertTrue( + repository.stalkerVodSearchQueries(title = " ", originalTitle = " ").isEmpty() + ) + } + + // ── Matching an entry found through the original title ──────────────── + + @Test + fun `an entry listed only under its original name is matched`() { + val repository = newRepository() + val items = listOf(item("1", "EN - The Shawshank Redemption (1994)", year = "1994")) + + val matches = repository.matchStalkerVodItems( + items = items, + normalizedTitle = "die verurteilten", + normalizedTmdb = null, + inputYear = 1994, + normalizedOriginalTitle = "the shawshank redemption" + ) + + assertEquals(listOf("1"), matches.map { it.id }) + } + + @Test + fun `without the original name that same entry stays unmatched`() { + val repository = newRepository() + val items = listOf(item("1", "EN - The Shawshank Redemption (1994)", year = "1994")) + + // The counter-proof to the test above: searching for the original + // title only helps if the match is allowed to use it as well. + assertTrue( + repository.matchStalkerVodItems( + items = items, + normalizedTitle = "die verurteilten", + normalizedTmdb = null, + inputYear = 1994 + ).isEmpty() + ) + } + + // ── Portal isolation (C1) ───────────────────────────────────────────── + + @Test + fun `two portals sharing an internal id produce different playback markers`() { + val first = StalkerVodLink.buildMarker("stalker1", "/media/file_1.mpg") + val second = StalkerVodLink.buildMarker("stalker2", "/media/file_1.mpg") + + assertNotEquals(first, second) + assertEquals("stalker1" to "/media/file_1.mpg", StalkerVodLink.parseMarker(first!!)) + assertEquals("stalker2" to "/media/file_1.mpg", StalkerVodLink.parseMarker(second!!)) + } + + @Test + fun `markers survive commands with slashes spaces and query parts`() { + val cmd = "/media/Some Movie (2021)/file?token=a/b&x=1" + val marker = StalkerVodLink.buildMarker("stalker1", cmd) + + assertTrue(StalkerVodLink.isMarker(marker!!)) + assertEquals("stalker1" to cmd, StalkerVodLink.parseMarker(marker)) + } + + @Test + fun `malformed markers and foreign urls are rejected`() { + assertNull(StalkerVodLink.parseMarker("https://example.com/movie.mp4")) + assertNull(StalkerVodLink.parseMarker("stalker_vod://stalker1")) + assertNull(StalkerVodLink.parseMarker("stalker_vod:///cmd")) + assertNull(StalkerVodLink.parseMarker("stalker_vod://stalker1/")) + assertNull(StalkerVodLink.buildMarker("stalker1", " ")) + assertNull(StalkerVodLink.buildMarker(" ", "/media/a.mpg")) + } + + @Test + fun `a placeholder counts as a direct source url`() { + val marker = StalkerVodLink.buildMarker("stalker1", "/media/1.mpg")!! + + assertTrue(isDirectStreamUrl(marker)) + assertTrue(isDirectStreamUrl("https://example.com/a.mp4")) + assertFalse(isDirectStreamUrl("magnet:?xt=urn:btih:abc")) + assertFalse(isDirectStreamUrl(null)) + assertFalse(isDirectStreamUrl(" ")) + } +} diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/IptvTitleNormalizerTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvTitleNormalizerTest.kt index 1f41ad125..ea02b31aa 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/IptvTitleNormalizerTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvTitleNormalizerTest.kt @@ -116,4 +116,42 @@ class IptvTitleNormalizerTest { val once = IptvTitleNormalizer.foldUmlautTranscription("fuer alle faelle") assertEquals(once, IptvTitleNormalizer.foldUmlautTranscription(once)) } + + // ── IPTV panel title noise (language prefixes, pipe tags) ───────────── + + @Test + fun `leading language markers used by iptv panels are stripped`() { + assertEquals("der herr der ringe", IptvTitleNormalizer.normalize("DE: Der Herr der Ringe")) + assertEquals("der herr der ringe", IptvTitleNormalizer.normalize("GER - Der Herr der Ringe")) + assertEquals("the dark knight", IptvTitleNormalizer.normalize("EN| The Dark Knight")) + assertEquals("le fabuleux destin", IptvTitleNormalizer.normalize("fr:Le Fabuleux Destin")) + } + + @Test + fun `pipe wrapped tags in front of the title are stripped`() { + assertEquals("breaking bad", IptvTitleNormalizer.normalize("|DE| Breaking Bad")) + assertEquals("breaking bad", IptvTitleNormalizer.normalize("|DE|HD| Breaking Bad")) + // Some panels use a box-drawing bar instead of a pipe. + assertEquals("breaking bad", IptvTitleNormalizer.normalize("\u2503DE\u2503 Breaking Bad")) + assertEquals("breaking bad", IptvTitleNormalizer.normalize("\u2503DE\u2503_Breaking_Bad_(2008)")) + } + + @Test + fun `a real title that looks like a language marker is left alone`() { + // The reason the language list is explicit: a generic two-letter prefix + // would turn these into "chapter two" and "the mission". + assertEquals("it chapter two", IptvTitleNormalizer.normalize("IT: Chapter Two")) + assertEquals("us", IptvTitleNormalizer.normalize("US")) + assertEquals("no country for old men", IptvTitleNormalizer.normalize("No Country for Old Men")) + } + + @Test + fun `stripping a marker still leaves both sides of a lookup equal`() { + // What the matching actually depends on: the panel spelling and the + // TMDB spelling have to normalize onto the same key. + assertEquals( + IptvTitleNormalizer.normalize("Dune"), + IptvTitleNormalizer.normalize("|DE|HD| Dune (2021)") + ) + } } diff --git a/app/src/test/kotlin/com/arflix/tv/ui/screens/details/AutoPlaySourcePlannerTest.kt b/app/src/test/kotlin/com/arflix/tv/ui/screens/details/AutoPlaySourcePlannerTest.kt index 8dcbedb88..d83f1cca2 100644 --- a/app/src/test/kotlin/com/arflix/tv/ui/screens/details/AutoPlaySourcePlannerTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/ui/screens/details/AutoPlaySourcePlannerTest.kt @@ -2,6 +2,7 @@ package com.arflix.tv.ui.screens.details import com.arflix.tv.data.model.StreamBehaviorHints import com.arflix.tv.data.model.StreamSource +import com.arflix.tv.ui.screens.player.eligiblePlayerAutoplayStreams import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -142,4 +143,39 @@ class AutoPlaySourcePlannerTest { url = "https://example.com/${source.hashCode()}", behaviorHints = StreamBehaviorHints(cached = cached, notWebReady = notWebReady) ) + + // ── Stalker VOD placeholder URLs ────────────────────────────────────── + + @Test + fun `a stalker vod placeholder counts as an autoplayable source`() { + // Reported on device: the source list showed the Stalker match and it + // played when picked by hand, but pressing play reported "no source + // matches this filter" - autoplay required an http url and the portal + // only issues one once playback resolves the placeholder. + val stalker = StreamSource( + source = "LEGO Star Wars: The Mandalorian", + addonName = "IPTV VOD", + addonId = "iptv_stalker_vod", + quality = "VOD", + size = "", + url = "stalker_vod://stalker1/%2Fmedia%2F1.mpg" + ) + + assertTrue(isAutoPlayableStream(stalker)) + assertEquals(listOf(stalker), eligiblePlayerAutoplayStreams(listOf(stalker), minimumQuality = 0)) + } + + @Test + fun `unresolvable urls stay out of autoplay`() { + val magnet = StreamSource( + source = "Movie 1080p", + addonName = "Torrentio", + addonId = "torrentio", + quality = "1080p", + size = "2 GB", + url = "magnet:?xt=urn:btih:abc" + ) + + assertFalse(isAutoPlayableStream(magnet)) + } }