Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/api/StalkerApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,106 @@ open class StalkerApi(
}
}

/**
* Ask the portal itself for movies matching [query] instead of downloading
* the whole catalog first.
*
* A Stalker portal only serves `get_ordered_list` in pages of (typically)
* 14 entries, so mirroring the Xtream approach - fetch the complete catalog,
* index it locally - would cost hundreds of requests per refresh on a large
* portal. `search` narrows the same endpoint server-side, which keeps a
* movie lookup at one request.
*
* Returns an empty list when the portal does not implement VOD listing at
* all: such builds answer with an HTML page or a bare `{"js":""}` under a
* plain HTTP 200, so success is measured on the parsed payload, never on the
* status code.
*/
suspend fun searchVod(
query: String,
maxPages: Int = DEFAULT_VOD_SEARCH_PAGES
): List<StalkerVodItem>? {
require(maxPages > 0) { "maxPages must be positive" }
val term = query.trim()
if (term.isBlank()) return emptyList()

val results = mutableListOf<StalkerVodItem>()
val seenKeys = HashSet<String>()
try {
val encodedTerm = java.net.URLEncoder.encode(term, "UTF-8")
var page = 1
while (page <= maxPages) {
// `category=0` means "every category" here. The category list
// spells the same idea as `id: "*"`, but get_ordered_list does
// not accept it: a portal that reads `*` as a literal category
// name finds nothing, or drops `search` altogether and answers
// with the head of its catalogue.
// `sortby=name` keeps the matches for one term together. Sorted
// by date added instead, a catalogue of six figures pushes them
// past [maxPages] purely by age.
val url = "$apiBase/server/load.php?type=vod&action=get_ordered_list" +
"&category=0&sortby=name&search=$encodedTerm&p=$page&JsHttpRequest=1-xml"
val response = doGet(url)
val parsed = gson.fromJson(response, StalkerVodResponse::class.java)
val data = parsed?.js?.data ?: break
if (data.isEmpty()) break

var newEntries = 0
for (item in data) {
val command = item.cmd?.trim().orEmpty()
if (command.isBlank()) continue
val key = item.id?.trim()?.ifBlank { null } ?: command
if (!seenKeys.add(key)) continue
newEntries++
results += item
}

val totalItems = parsed.js?.totalItems ?: 0
val maxPageItems = (parsed.js?.maxPageItems ?: data.size).coerceAtLeast(1)
// Some portals ignore `p` and answer every page with the same
// result set - stop as soon as a page adds nothing new. A
// portal that reports no total at all keeps paging until then
// or until [maxPages].
if (newEntries == 0) break
if (totalItems > 0 && data.size >= totalItems) break
if (totalItems > 0 && page * maxPageItems >= totalItems) break
page++
}
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e

System.err.println("[Stalker] VOD search failed: ${e.message}")
// null, not the partial list: the caller caches what it gets back,
// and a failed request must never be stored as "this portal has
// nothing" - see the null contract on the return type.
return null
}
return results
}

/**
* Exchange a VOD `cmd` for a playable URL (`type=vod&action=create_link`).
*
* Only ever called when playback actually starts - see [StalkerVodItem].
*/
suspend fun resolveVodStreamUrl(cmd: String): String? {
val command = cmd.trim()
if (command.isBlank()) return null
return try {
val encodedCmd = java.net.URLEncoder.encode(command, "UTF-8")
val url = "$apiBase/server/load.php?type=vod&action=create_link&cmd=$encodedCmd" +
"&forced_storage=undefined&disable_ad=0&JsHttpRequest=1-xml"
val response = doGet(url)
val parsed = gson.fromJson(response, StalkerLinkResponse::class.java)
sanitizePlaybackCommand(parsed?.js?.cmd)
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e

System.err.println("[Stalker] Resolve VOD stream failed: ${e.message}")
null
}
}

/** Resolve a channel's cmd to a playable stream URL */
suspend fun resolveStreamUrl(cmd: String): String? {
return try {
Expand Down Expand Up @@ -515,6 +615,60 @@ open class StalkerApi(
data class StalkerLinkResponse(val js: StalkerLink?)
data class StalkerLink(val cmd: String?)

/**
* One entry of the portal's VOD catalog.
*
* Every field is a String because portals disagree on whether ids, years
* and ratings arrive as JSON numbers or strings; Gson accepts both for a
* String field but throws on a mismatched primitive type, which would lose
* the whole response. [cmd] is the token that has to go through
* `create_link` before it can be played. [tmdbId] is only filled in by some
* portal builds - matching falls back to title and year without it.
*/
data class StalkerVodItem(
val id: String? = null,
val name: String? = null,
val cmd: String? = null,
val year: String? = null,
/** Runtime in minutes on most builds; a few send "hh:mm:ss" instead. */
val time: String? = null,
/** 1 when the portal flags the entry as HD. Not an actual resolution. */
val hd: String? = null,
@SerializedName("screenshot_uri") val screenshotUri: String? = null,
@SerializedName("rating_imdb") val ratingImdb: String? = null,
@SerializedName(value = "tmdb_id", alternate = ["tmdb", "tmdbid"]) val tmdbId: String? = null,
@SerializedName("category_id") val categoryId: String? = null
)

data class StalkerVodResponse(val js: StalkerVodData?)
data class StalkerVodData(
val data: List<StalkerVodItem>?,
@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<StalkerEpgProgram?>?)

/** Field names vary by portal software/version, hence the alternates. */
Expand Down
24 changes: 24 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/model/IptvVodSourceIds.kt
Original file line number Diff line number Diff line change
@@ -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<String> = setOf(XTREAM, STALKER)

fun isIptvVodAddonId(addonId: String?): Boolean {
val id = addonId?.trim()?.lowercase(Locale.US) ?: return false
return id in ALL
}
}
5 changes: 5 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ data class MediaItem(
val badge: String? = null,
val genreIds: List<Int> = 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/data/model/StalkerVodLink.kt
Original file line number Diff line number Diff line change
@@ -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://<portalId>/<urlencoded cmd>`. 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<String, String>? {
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)
}
Loading
Loading