From 29195656381fe8b7452dc2e29f230f00a660e78e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 18:36:39 +0000 Subject: [PATCH 1/5] feat(stalker): add VOD movie source resolution Stalker/Ministra portals contributed nothing to ARVIO's movie source search: findMovieVodSources only queried Xtream's get_vod_streams and returned empty as soon as no Xtream credentials were configured. This adds a Stalker path alongside the existing Xtream one: - StalkerApi: searchVod (type=vod&action=get_ordered_list with a search term) and resolveVodStreamUrl (type=vod&action=create_link). - IptvRepository: per-portal Stalker VOD search with a bounded result cache, TMDB/IMDb id matching with a normalised title+year fallback, and an additive merge into findMovieVodSources. - StalkerVodLink: the stalker_vod:// placeholder a Stalker source carries until playback, plus isDirectStreamUrl as the single answer to "is this URL playable now, or only once resolved". - StreamRepository.resolveStreamInternal: resolve that placeholder through the existing lazy-resolve hook, so listing a catalogue never triggers create_link. - Autoplay, the source selector and the player rank Stalker sources by isDirectStreamUrl instead of assuming an http URL. - warmXtreamVodCachesIfPossible is renamed to warmVodCachesIfPossible and warms the Stalker portal handshake at all three existing call sites. The Xtream path is unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MEhCFVC5VvcTgH1SAjbcyC --- .../com/arflix/tv/data/api/StalkerApi.kt | 142 ++++++++ .../arflix/tv/data/model/IptvVodSourceIds.kt | 24 ++ .../tv/data/model/SportsAddonCapabilities.kt | 2 +- .../arflix/tv/data/model/StalkerVodLink.kt | 64 ++++ .../tv/data/repository/IptvRepository.kt | 331 +++++++++++++++++- .../tv/data/repository/StreamRepository.kt | 17 +- .../arflix/tv/ui/components/StreamSelector.kt | 8 +- .../screens/details/AutoPlaySourcePlanner.kt | 6 +- .../tv/ui/screens/details/DetailsViewModel.kt | 3 +- .../tv/ui/screens/home/HomeViewModel.kt | 4 +- .../tv/ui/screens/player/PlayerViewModel.kt | 8 +- .../ui/screens/settings/SettingsViewModel.kt | 2 +- .../arflix/tv/ui/screens/tv/TvViewModel.kt | 2 +- .../com/arflix/tv/data/api/StalkerApiTest.kt | 154 ++++++++ .../IptvRepositoryStalkerVodTest.kt | 225 ++++++++++++ .../repository/IptvTitleNormalizerTest.kt | 38 ++ .../details/AutoPlaySourcePlannerTest.kt | 36 ++ 17 files changed, 1045 insertions(+), 21 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/data/model/IptvVodSourceIds.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/model/StalkerVodLink.kt create mode 100644 app/src/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryStalkerVodTest.kt 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..bfa6f6ae5 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,94 @@ 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) { + val url = "$apiBase/server/load.php?type=vod&action=get_ordered_list" + + "&category=*&sortby=added&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}") + } + 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 +603,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/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..226220b22 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,28 @@ 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 + private val maxStalkerVodSearchCacheEntries = 64 + /** * Public accessor kept for compatibility with code that previously read the * single cached Stalker API instance. Returns the first cached portal API. @@ -3712,6 +3765,7 @@ class IptvRepository @Inject constructor( cachedEpgAt = 0L stalkerEpgCache.clear() stalkerShortEpgCache.clear() + stalkerVodSearchCache.clear() discoveredM3uEpgUrls.clear() xtreamVodCacheKey = null xtreamVodLoadedAtMs = 0L @@ -3743,7 +3797,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 @@ -5297,7 +5351,7 @@ class IptvRepository @Inject constructor( 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 +5364,22 @@ 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 + ) + }.getOrDefault(emptyList()) + } + sortVodSources(xtreamSources + stalkerSources) } } @@ -5405,6 +5474,240 @@ 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 + ): 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 inputYear = year ?: parseYear(title) + + var matches: List = emptyList() + for (query in stalkerVodSearchQueries(title)) { + val items = stalkerVodSearch(portal, fingerprint, api, query) + if (items.isEmpty()) continue + matches = matchStalkerVodItems(items, normalizedTitle, normalizedTmdb, inputYear) + if (matches.isNotEmpty()) break + } + System.err.println("[Stalker-VOD] portal=${portal.id} title='$title' 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 + } + + /** + * At most two portal queries per lookup: the plain title first and, only + * when the title carries a subtitle, the part in front of it - panels + * frequently list "Dune" where TMDB says "Dune: Part Two". The second query + * is skipped as soon as the first one produced a match. + */ + internal fun stalkerVodSearchQueries(title: String): List { + val primary = title.trim() + if (primary.isBlank()) return emptyList() + val head = primary.substringBefore(':').substringBefore(" - ").trim() + return if (head.length >= 3 && !head.equals(primary, ignoreCase = true)) { + listOf(primary, head) + } else { + listOf(primary) + } + } + + 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 < stalkerVodSearchCacheTtlMs) return cached.items + stalkerVodSearchCache.remove(key) + } + val items = api.searchVod(term) + 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 + } + + /** + * Same two stages as 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. + */ + internal fun matchStalkerVodItems( + items: List, + normalizedTitle: String, + normalizedTmdb: String?, + inputYear: Int? + ): List { + if (items.isEmpty()) return emptyList() + if (!normalizedTmdb.isNullOrBlank()) { + val idMatches = items.filter { normalizeTmdbId(it.tmdbId) == normalizedTmdb } + if (idMatches.isNotEmpty()) return idMatches + } + if (normalizedTitle.isBlank()) return emptyList() + + val scored = items + .mapNotNull { item -> + val name = item.name?.trim().orEmpty() + if (name.isBlank()) return@mapNotNull null + if (item.cmd.isNullOrBlank()) return@mapNotNull null + val score = scoreNameMatch(name, normalizedTitle) + if (score <= 0) return@mapNotNull null + val providerYear = parseYear(item.year?.trim().orEmpty().ifBlank { name }) + 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 +5780,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 +5946,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 +5964,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 +5982,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/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index c2324675b..6357aa2f2 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" @@ -3658,6 +3660,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..1eea6a168 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 && 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..4051051d5 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) 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..8b19c3f49 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,158 @@ 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=*")) + // Matching must never cost a link: create_link happens at playback only. + assertTrue(requests.none { it.contains("action=create_link") }) + } + + @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 treats an HTML 200 answer as unsupported`() = runTest { + val requests = mutableListOf() + val api = stubApi(requests = requests) { "Not found" } + + assertTrue(api.searchVod("Dune").isEmpty()) + } + + @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..e4e1a7879 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryStalkerVodTest.kt @@ -0,0 +1,225 @@ +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")) + } + + // ── 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)) + } } From b7c495f947a174c870d92616ec9fd4aba70c3c89 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 13:26:53 +0000 Subject: [PATCH 2/5] fix(stalker): stop caching a failed lookup as "the portal has nothing" Reported from the device: a series that played fine earlier stopped being found, while other series on the same portal kept working, and which ones failed changed between sessions. The logs show the pattern - 'The Gentlemen' answered shows=0 four times in the morning and resolved normally later the same day, with no code in between. Both search paths swallowed their exception and returned the partial (usually empty) result list, so a request that never got through was indistinguishable from a portal that genuinely knows no such title. The repository then cached that empty list for six hours. One timeout, one rate-limited moment, and the title stayed missing for the rest of the day - on a portal that had it all along. searchVod, searchSeries and getSeasons now return null when the request failed and an empty list when the portal answered with nothing, and the three cache wrappers refuse to store the null. A genuine empty answer is still cached, because it does stop a browsed-past show from asking again on every screen, but only for ten minutes: it is a real answer, yet not one worth being wrong about for six hours. getSeasons is included deliberately. A failed season fetch cached as "no seasons" leaves a show bound but unplayable, which looks like a matching bug and is the harder half to diagnose. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tcdagn2kP2ZaveybkNEdo9 --- .../com/arflix/tv/data/api/StalkerApi.kt | 6 +++++- .../tv/data/repository/IptvRepository.kt | 19 +++++++++++++++++-- .../com/arflix/tv/data/api/StalkerApiTest.kt | 16 +++++++++------- 3 files changed, 31 insertions(+), 10 deletions(-) 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 bfa6f6ae5..e69f5beb6 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 @@ -458,7 +458,7 @@ open class StalkerApi( suspend fun searchVod( query: String, maxPages: Int = DEFAULT_VOD_SEARCH_PAGES - ): List { + ): List? { require(maxPages > 0) { "maxPages must be positive" } val term = query.trim() if (term.isBlank()) return emptyList() @@ -501,6 +501,10 @@ open class StalkerApi( 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 } 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 226220b22..a250b19a1 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 @@ -438,6 +438,15 @@ class IptvRepository @Inject constructor( 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 /** @@ -5549,6 +5558,10 @@ class IptvRepository @Inject constructor( * frequently list "Dune" where TMDB says "Dune: Part Two". The second query * is skipped as soon as the first one produced a match. */ + /** Empty answers expire quickly, real hits keep the long TTL. */ + private fun cacheTtlFor(items: List<*>): Long = + if (items.isEmpty()) stalkerVodSearchEmptyCacheTtlMs else stalkerVodSearchCacheTtlMs + internal fun stalkerVodSearchQueries(title: String): List { val primary = title.trim() if (primary.isBlank()) return emptyList() @@ -5571,10 +5584,12 @@ class IptvRepository @Inject constructor( val key = StalkerVodSearchCacheKey(portal.id, fingerprint, term.lowercase(Locale.US)) val now = System.currentTimeMillis() stalkerVodSearchCache[key]?.let { cached -> - if (now - cached.fetchedAtMs < stalkerVodSearchCacheTtlMs) return cached.items + if (now - cached.fetchedAtMs < cacheTtlFor(cached.items)) return cached.items stalkerVodSearchCache.remove(key) } - val items = api.searchVod(term) + // 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. 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 8b19c3f49..b145833d8 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 @@ -727,7 +727,7 @@ class StalkerApiTest { } } - val items = api.searchVod("Dune") + val items = api.searchVod("Dune")!! assertEquals(1, items.size) assertEquals("Dune (2021)", items.first().name) @@ -760,7 +760,7 @@ class StalkerApiTest { } } - val items = api.searchVod("Alien") + val items = api.searchVod("Alien")!! assertEquals(listOf("Alien", "Aliens", "Alien 3"), items.map { it.name }) assertEquals(2, requests.size) @@ -781,7 +781,7 @@ class StalkerApiTest { } } - val items = api.searchVod("Heat") + val items = api.searchVod("Heat")!! assertEquals(1, items.size) // Page 2 repeats page 1 - no new ids means stop, not 999 requests. @@ -789,11 +789,13 @@ class StalkerApiTest { } @Test - fun `searchVod treats an HTML 200 answer as unsupported`() = runTest { + 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" } - assertTrue(api.searchVod("Dune").isEmpty()) + // 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 @@ -807,7 +809,7 @@ class StalkerApiTest { """.trimIndent() } - assertEquals(listOf("Playable"), api.searchVod("x").map { it.name }) + assertEquals(listOf("Playable"), api.searchVod("x")!!.map { it.name }) } @Test @@ -815,7 +817,7 @@ class StalkerApiTest { val requests = mutableListOf() val api = stubApi(requests = requests) { null } - assertTrue(api.searchVod(" ").isEmpty()) + assertTrue(api.searchVod(" ")!!.isEmpty()) assertTrue(requests.isEmpty()) } From c8aa500ed8012405c4c763134f4765e2fd743df4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 17:17:45 +0000 Subject: [PATCH 3/5] fix(stalker): report how much a portal offered, not just what matched `matches=0` and `shows=0` could not tell "the portal sent nothing" apart from "the portal sent entries and none of them matched". Both read as a dead end, so diagnosing one cost a full day of guessing. Portals that ignore the `search` parameter answer every lookup with the head of their whole catalogue. Logging the offered count next to the match count names that case on sight: entries offered, none matched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016QQNABzYtmqbKnHmVAWoNL --- .../com/arflix/tv/data/repository/IptvRepository.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 a250b19a1..8b501f26d 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 @@ -5532,13 +5532,22 @@ class IptvRepository @Inject constructor( 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)) { val items = stalkerVodSearch(portal, fingerprint, api, query) + offered += items.size if (items.isEmpty()) continue matches = matchStalkerVodItems(items, normalizedTitle, normalizedTmdb, inputYear) if (matches.isNotEmpty()) break } - System.err.println("[Stalker-VOD] portal=${portal.id} title='$title' matches=${matches.size}") + System.err.println( + "[Stalker-VOD] portal=${portal.id} title='$title' " + + "offered=$offered matches=${matches.size}" + ) if (matches.isEmpty()) return emptyList() val sources = sortVodSources( From cd7b260d74b53bc8c20aab0490f5bd8ca3c528aa Mon Sep 17 00:00:00 2001 From: ReichiMD Date: Wed, 9 Sep 2026 22:30:44 +0000 Subject: [PATCH 4/5] fix(stalker): ask the portal the way it expects to be asked for VOD Searching a Stalker portal for a title it has was answering with nothing. Live TV kept working on the same portal with the same MAC, so the handshake and the token were never in question - only get_ordered_list for type=vod and type=series was. Two parameters explain it, both measured against a full client talking to the same portal, which finds 13 shows and 17 movies for a term we found nothing for: category=* -> category=0 sortby=added -> sortby=name The `*` is the category list's own word for "all", where it is the id of a pseudo category. get_ordered_list does not share that vocabulary: a portal reading `*` as the name of a category finds none, or drops `search` altogether and answers with the head of its catalogue - which is exactly the shape of the failure, since nothing in that head matches. sortby=added explains the rest. Ordered by the date a title was added and stopped after DEFAULT_VOD_SEARCH_PAGES, a match sits behind everything added since; on a catalogue of 104021 movies that is not an edge case. By name, the matches for one term stay inside the cap. getSeasons loses its sortby entirely. A show addressed by movie_id needs no order imposed on it, a full client sends none, and a build that reads the parameter as a filter would answer with nothing. The page cap stays at 3. It is what keeps a search a search rather than a catalogue download, and with the portal both filtering and sorting, three pages hold the matches for a term - the same client needed two. Tests now assert the request address itself, not just that a list comes back: every one of them fails against the old parameters. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FXM9ii7CJn4qCR6q7Mz6Pz --- .../com/arflix/tv/data/api/StalkerApi.kt | 10 +++- .../com/arflix/tv/data/api/StalkerApiTest.kt | 54 ++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) 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 e69f5beb6..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 @@ -469,8 +469,16 @@ open class StalkerApi( 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=*&sortby=added&search=$encodedTerm&p=$page&JsHttpRequest=1-xml" + "&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 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 b145833d8..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 @@ -735,11 +735,63 @@ class StalkerApiTest { 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=*")) + 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() From 53a5698e8ca1f1601ff7caae47956917479fda4d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 09:16:14 +0000 Subject: [PATCH 5/5] fix(stalker): search a portal for the name its catalogue actually uses A Stalker 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 the same film as "DE - Der Astronaut: Project Hail Mary (2026)" with a colon, and the portal answers total_items 0 - while carrying that film eleven more times under its original title. Switching the app's content language to English found and played it within seconds, changing nothing but the search term. One lookup may now spend up to three terms, stopping at the first one that produces a match: 1. the original title (TMDB original_title / original_name) 2. the displayed title, as before 3. the part in front of a subtitle separator, as before None of the three is enough alone. Original-only fails on a title localized without its original name in it - "Die Verurteilten" does not contain "The Shawshank Redemption". Displayed-only fails on the punctuation mismatch above. The head is the rescue anchor for both, and its separator list learns the en and em dash, which is how TMDB punctuates a subtitle where portals write a colon. The common case does not get more expensive: when a user browses in the original language, terms 1 and 2 are the same string and exactly one request goes out, as before. Only a failed lookup escalates. Matching accepts the original title as an equal alternative rather than a fallback. Without that, asking for the original name would find a catalogue that lists a film only under it - "EN - Money Heist" for a user browsing in German - and then discard the entry for not being the displayed title. Portals that supply a tmdb_id never reach this stage and are unaffected. MediaItem carries the original name for that purpose. It is nullable and stays null on items restored from an older JSON cache, so a missing value reads as "unknown", never as "same as the title". Series go through the same term list, and the series warm-up path uses it too - warming has to ask what the real lookup will ask, or it binds a show the lookup then searches for again. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tngwo6C91UcEiTC2713hg3 --- .../kotlin/com/arflix/tv/data/model/Models.kt | 5 + .../tv/data/repository/IptvRepository.kt | 178 ++++++++++++++---- .../tv/data/repository/MediaRepository.kt | 4 + .../tv/data/repository/StreamRepository.kt | 6 +- .../tv/ui/screens/details/DetailsViewModel.kt | 7 +- .../tv/ui/screens/player/PlayerViewModel.kt | 6 +- .../IptvRepositoryStalkerVodTest.kt | 115 +++++++++++ 7 files changed, 285 insertions(+), 36 deletions(-) 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/repository/IptvRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/IptvRepository.kt index 8b501f26d..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 @@ -449,6 +449,13 @@ class IptvRepository @Inject constructor( 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. @@ -5355,7 +5362,8 @@ 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() @@ -5384,7 +5392,8 @@ class IptvRepository @Inject constructor( year = year, tmdbId = tmdbId, imdbId = imdbId, - allowNetwork = allowNetwork + allowNetwork = allowNetwork, + originalTitle = originalTitle ) }.getOrDefault(emptyList()) } @@ -5503,7 +5512,8 @@ class IptvRepository @Inject constructor( year: Int?, tmdbId: Int?, imdbId: String?, - allowNetwork: Boolean + allowNetwork: Boolean, + originalTitle: String? = null ): List { if (portal.portalUrl.isBlank() || portal.macAddress.isBlank()) return emptyList() val fingerprint = stalkerPortalFingerprint(portal) @@ -5529,6 +5539,8 @@ class IptvRepository @Inject constructor( 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() @@ -5537,11 +5549,17 @@ class IptvRepository @Inject constructor( // 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)) { + for (query in stalkerVodSearchQueries(title, originalTitle)) { val items = stalkerVodSearch(portal, fingerprint, api, query) offered += items.size if (items.isEmpty()) continue - matches = matchStalkerVodItems(items, normalizedTitle, normalizedTmdb, inputYear) + matches = matchStalkerVodItems( + items = items, + normalizedTitle = normalizedTitle, + normalizedTmdb = normalizedTmdb, + inputYear = inputYear, + normalizedOriginalTitle = normalizedOriginalTitle + ) if (matches.isNotEmpty()) break } System.err.println( @@ -5561,27 +5579,80 @@ class IptvRepository @Inject constructor( return sources } - /** - * At most two portal queries per lookup: the plain title first and, only - * when the title carries a subtitle, the part in front of it - panels - * frequently list "Dune" where TMDB says "Dune: Part Two". The second query - * is skipped as soon as the first one produced a match. - */ /** Empty answers expire quickly, real hits keep the long TTL. */ private fun cacheTtlFor(items: List<*>): Long = if (items.isEmpty()) stalkerVodSearchEmptyCacheTtlMs else stalkerVodSearchCacheTtlMs - internal fun stalkerVodSearchQueries(title: String): List { + /** + * 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() - if (primary.isBlank()) return emptyList() - val head = primary.substringBefore(':').substringBefore(" - ").trim() - return if (head.length >= 3 && !head.equals(primary, ignoreCase = true)) { - listOf(primary, head) - } else { - listOf(primary) - } + 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, @@ -5608,33 +5679,76 @@ class IptvRepository @Inject constructor( 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? + ) + /** - * Same two stages as the Xtream path: a portal-supplied `tmdb_id` wins + * 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. + * [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. */ - internal fun matchStalkerVodItems( - items: List, + private fun matchStalkerCatalogEntries( + items: List, normalizedTitle: String, normalizedTmdb: String?, - inputYear: Int? - ): List { + inputYear: Int?, + normalizedOriginalTitle: String? = null, + fields: (T) -> StalkerCatalogFields + ): List { if (items.isEmpty()) return emptyList() if (!normalizedTmdb.isNullOrBlank()) { - val idMatches = items.filter { normalizeTmdbId(it.tmdbId) == normalizedTmdb } + val idMatches = items.filter { normalizeTmdbId(fields(it).tmdbId) == normalizedTmdb } if (idMatches.isNotEmpty()) return idMatches } - if (normalizedTitle.isBlank()) return emptyList() + val wantedNames = listOfNotNull( + normalizedTitle.takeIf { it.isNotBlank() }, + normalizedOriginalTitle?.takeIf { it.isNotBlank() } + ).distinct() + if (wantedNames.isEmpty()) return emptyList() val scored = items .mapNotNull { item -> - val name = item.name?.trim().orEmpty() - if (name.isBlank()) return@mapNotNull null - if (item.cmd.isNullOrBlank()) return@mapNotNull null - val score = scoreNameMatch(name, normalizedTitle) + 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(item.year?.trim().orEmpty().ifBlank { name }) + val providerYear = parseYear(entry.year?.trim().orEmpty().ifBlank { itemName }) val yearDelta = if (inputYear != null && providerYear != null) { kotlin.math.abs(providerYear - inputYear) } else null 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 6357aa2f2..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 @@ -2454,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 { @@ -2463,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}") 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 1eea6a168..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 @@ -354,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, @@ -3048,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( @@ -3055,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/player/PlayerViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerViewModel.kt index 4051051d5..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 @@ -5039,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( @@ -5046,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/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryStalkerVodTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryStalkerVodTest.kt index e4e1a7879..46a29bd71 100644 --- a/app/src/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryStalkerVodTest.kt +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/IptvRepositoryStalkerVodTest.kt @@ -181,6 +181,121 @@ class IptvRepositoryStalkerVodTest { 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