CloudStream per-catalog feeds and home billboard integration

- Add Cs3CatalogFeedDescriptor and per-catalog feed keys (cs3CatalogFeedKey) so
  each CS3 catalog gets its own home row instead of one per plugin
- Build CS3 metadata feed options from per-catalog descriptors in buildCs3MetadataFeedOptions
- HomeBillboardLoader now fetches CS3 feeds alongside stremio addon feeds for
  billboard pool population
- Stream.kt: send Cloudstream referer header lowercase ("referer") to match how
  Media3 passes custom request properties
- HomeContentFormatters: skip cs3: video IDs in parseShortVideoId to avoid
  incorrect episode line parsing
- HomeLayoutHelpers: include poster as fallback in mobileHeroArtworkCandidates
This commit is contained in:
KhooLy 2026-07-03 12:53:00 +03:00
parent 05083b3f0b
commit b91c91f205
7 changed files with 158 additions and 25 deletions

View file

@ -3,6 +3,10 @@ package com.fluxa.app.data.repository
import android.util.Base64
import android.util.Log
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.domain.discovery.cs3CatalogFeedKey
import com.fluxa.app.domain.discovery.cs3PluginFeedKey
import com.fluxa.app.plugins.cloudstream.ExternalExtensionRunner
import com.fluxa.app.plugins.cloudstream.ScraperSearchResult
import com.fluxa.app.ui.catalog.HomeCategory
import com.lagradost.cloudstream3.AnimeSearchResponse
import com.lagradost.cloudstream3.MainAPI
@ -24,8 +28,6 @@ import javax.inject.Singleton
private const val TAG = "CS3CatalogClient"
private const val ROW_TIMEOUT_MS = 20_000L
private const val MAX_ITEMS_PER_ROW = 25
@Singleton
class CloudStreamCatalogClient @Inject constructor() {
@ -34,7 +36,8 @@ class CloudStreamCatalogClient @Inject constructor() {
*/
suspend fun fetchHomeCatalogCategories(
apis: List<MainAPI>,
iconsByApiName: Map<String, String> = emptyMap()
iconsByApiName: Map<String, String> = emptyMap(),
enabledFeedKeys: Set<String>? = null
): List<HomeCategory> =
withContext(Dispatchers.IO) {
val supported = apis.filter { it.hasMainPage }
@ -43,7 +46,7 @@ class CloudStreamCatalogClient @Inject constructor() {
supported.map { api ->
async {
try {
fetchApiCatalogRows(api, iconsByApiName[api.name])
fetchApiCatalogRows(api, iconsByApiName[api.name], enabledFeedKeys)
} catch (t: Throwable) {
Log.w(TAG, "Catalog fetch failed for ${api.name}", t)
emptyList()
@ -53,10 +56,62 @@ class CloudStreamCatalogClient @Inject constructor() {
}
}
private suspend fun fetchApiCatalogRows(api: MainAPI, iconUrl: String? = null): List<HomeCategory> {
suspend fun fetchFeedItems(
apis: List<MainAPI>,
feedKey: String,
page: Int = 1
): List<Meta> =
withContext(Dispatchers.IO) {
apis.filter { it.hasMainPage }.forEach { api ->
val items = fetchApiFeedItems(api, feedKey, page)
if (items.isNotEmpty()) return@withContext items
}
emptyList()
}
suspend fun searchRows(
apis: List<MainAPI>,
query: String
): List<SearchResultRow> =
withContext(Dispatchers.IO) {
if (query.isBlank() || apis.isEmpty()) return@withContext emptyList()
val runner = ExternalExtensionRunner()
coroutineScope {
apis.map { api ->
async {
try {
val metas = runner.searchScraper(api, query)
.mapNotNull { it.toMeta(api.name) }
if (metas.isEmpty()) return@async null
SearchResultRow(
title = api.name,
items = metas,
id = "cs3_search_${api.name}",
type = metas.first().type
)
} catch (t: Throwable) {
Log.w(TAG, "Search failed for ${api.name}", t)
null
}
}
}.awaitAll().filterNotNull()
}
}
private suspend fun fetchApiCatalogRows(
api: MainAPI,
iconUrl: String? = null,
enabledFeedKeys: Set<String>? = null
): List<HomeCategory> {
val result = mutableListOf<HomeCategory>()
api.mainPage.forEachIndexed { pageIdx, pageData ->
try {
val catalogName = pageData.name.takeIf { it.isNotBlank() } ?: api.name
val feedKey = cs3CatalogFeedKey(api.name, catalogName, pageIdx)
val legacyPluginKey = cs3PluginFeedKey(api.name)
if (enabledFeedKeys != null && feedKey !in enabledFeedKeys && legacyPluginKey !in enabledFeedKeys) {
return@forEachIndexed
}
val response = withTimeoutOrNull(ROW_TIMEOUT_MS) {
api.getMainPage(
1,
@ -69,13 +124,13 @@ class CloudStreamCatalogClient @Inject constructor() {
} ?: return@forEachIndexed
response.items.forEachIndexed { listIdx, pageList ->
val metas = pageList.list.take(MAX_ITEMS_PER_ROW).mapNotNull { it.toMeta(api) }
val metas = pageList.list.mapNotNull { it.toMeta(api) }
if (metas.isEmpty()) return@forEachIndexed
val categoryId = "cs3_${api.name.sanitize()}_${pageIdx}_$listIdx"
val categoryId = if (listIdx == 0) feedKey else "${feedKey}_${listIdx}"
result.add(
HomeCategory(
name = pageList.name.takeIf { it.isNotBlank() }
?: "${api.name}: ${pageData.name}",
?: "${api.name}: $catalogName",
items = metas,
id = categoryId,
type = metas.first().type,
@ -91,6 +146,36 @@ class CloudStreamCatalogClient @Inject constructor() {
return result
}
private suspend fun fetchApiFeedItems(
api: MainAPI,
feedKey: String,
page: Int
): List<Meta> {
val legacyPluginKey = cs3PluginFeedKey(api.name)
api.mainPage.forEachIndexed { pageIdx, pageData ->
val catalogName = pageData.name.takeIf { it.isNotBlank() } ?: api.name
val catalogFeedKey = cs3CatalogFeedKey(api.name, catalogName, pageIdx)
if (feedKey != catalogFeedKey && feedKey != legacyPluginKey) return@forEachIndexed
return try {
val response = withTimeoutOrNull(ROW_TIMEOUT_MS) {
api.getMainPage(
page,
MainPageRequest(
data = pageData.data,
name = pageData.name,
horizontalImages = pageData.horizontalImages
)
)
} ?: return emptyList()
response.items.flatMap { pageList -> pageList.list.mapNotNull { it.toMeta(api) } }
} catch (t: Throwable) {
Log.w(TAG, "Feed '$catalogName' from ${api.name} failed: ${t.javaClass.simpleName}: ${t.message}")
emptyList()
}
}
return emptyList()
}
private fun SearchResponse.toMeta(api: MainAPI): Meta? {
val title = name.takeIf { it.isNotBlank() } ?: return null
val id = resolveId(this, api)
@ -111,6 +196,19 @@ class CloudStreamCatalogClient @Inject constructor() {
)
}
private fun ScraperSearchResult.toMeta(apiName: String): Meta? {
val title = this.title.takeIf { it.isNotBlank() } ?: return null
val id = encodeCsId(apiName, url)
return Meta(
id = id,
name = title,
type = type?.toStremioType() ?: "movie",
poster = posterUrl?.trim()?.takeIf { it.isNotBlank() }
?.let { if (it.startsWith("http://")) it.replaceFirst("http://", "https://") else it },
releaseInfo = year?.toString()
)
}
private fun resolveId(result: SearchResponse, api: MainAPI): String {
if (api is TmdbProvider) {
result.id?.let { return "tmdb:$it" }
@ -123,8 +221,6 @@ class CloudStreamCatalogClient @Inject constructor() {
Regex("""themoviedb\.org/(?:movie|tv)/(\d+)""")
.find(url)?.groupValues?.get(1)?.toIntOrNull()
private fun String.sanitize() = replace(Regex("[^a-zA-Z0-9]"), "_").lowercase()
companion object {
fun encodeCsId(apiName: String, data: String): String {
val n = Base64.encodeToString(apiName.toByteArray(), Base64.NO_WRAP or Base64.NO_PADDING)

View file

@ -3,8 +3,10 @@ package com.fluxa.app.data.repository
import android.util.Log
import com.fluxa.app.BuildConfig
import com.fluxa.app.data.remote.Stream
import com.fluxa.app.data.remote.SubtitleData
import com.fluxa.app.plugins.cloudstream.ExternalExtensionRunner
import com.fluxa.app.plugins.cloudstream.ScraperSearchResult
import com.fluxa.app.plugins.cloudstream.ScraperSubtitle
import com.fluxa.app.plugins.cloudstream.ScraperStreamLink
import com.lagradost.cloudstream3.metaproviders.TmdbProvider
import kotlinx.coroutines.async
@ -82,7 +84,8 @@ class CloudStreamDiscoveryClient @Inject constructor() {
Log.w("StremioRepo", "CS3 ${api.name}: no content data for type=$type s=$season e=$episode")
return emptyList()
}
return runner.loadStreams(api, streamData).links.map { it.toStream(api.name) }
val streamResult = runner.loadStreams(api, streamData)
return streamResult.links.map { it.toStream(api.name, streamResult.subtitles) }
}
private suspend fun loadSearchProviderStreams(
@ -134,20 +137,30 @@ class CloudStreamDiscoveryClient @Inject constructor() {
Log.w("StremioRepo", "CS3 ${api.name}: no content data for type=$type s=$season e=$episode")
return emptyList()
}
return runner.loadStreams(api, streamData).links.map { it.toStream(api.name) }
val streamResult = runner.loadStreams(api, streamData)
return streamResult.links.map { it.toStream(api.name, streamResult.subtitles) }
}
private fun ScraperStreamLink.toStream(addonName: String) = Stream(
private fun ScraperStreamLink.toStream(addonName: String, subtitles: List<ScraperSubtitle>) = Stream(
name = " $addonName\n$quality",
title = name,
url = url,
subtitles = subtitles.map { it.toSubtitleData() },
behaviorHints = buildMap {
put("proxyHeaders", buildMap { put("request", headers) })
if (referer != null) put("referer", referer)
put("cs3Type", type)
put("isM3u8", isM3u8)
put("isDash", isDash)
},
addonName = " $addonName"
)
private fun ScraperSubtitle.toSubtitleData() = SubtitleData(
url = url,
lang = lang
)
private inline fun logDebug(tag: String, message: () -> String) {
if (BuildConfig.DEBUG) Log.d(tag, message())
}

View file

@ -17,6 +17,8 @@ internal class HomeBillboardLoader(
private val addonRepository: AddonRepository,
private val scope: CoroutineScope,
private val getMetadataFeeds: suspend (UserProfile?) -> List<MetadataFeedOption>,
private val getCs3MetadataFeeds: () -> List<MetadataFeedOption>,
private val fetchCs3FeedItems: suspend (MetadataFeedOption) -> List<Meta>,
private val setPool: (List<Meta>) -> Unit,
private val updateContent: suspend (Meta) -> Unit,
private val normalizePool: (List<Meta>) -> List<Meta>,
@ -25,7 +27,7 @@ internal class HomeBillboardLoader(
suspend fun load(profile: UserProfile?) {
val lang = profile?.safeLanguage ?: "en"
try {
val enabledFeeds = getMetadataFeeds(profile)
val enabledFeeds = (getMetadataFeeds(profile) + getCs3MetadataFeeds())
.let { orderedMetadataFeeds(it, profile?.heroFeedOrder) }
.let { feeds ->
val availableKeys = feeds.map { it.key }
@ -37,7 +39,11 @@ internal class HomeBillboardLoader(
val spotlightCandidates = enabledFeeds
.map { feed ->
scope.async(Dispatchers.IO) {
addonRepository.getAddonCatalog(feed.transportUrl, feed.type, feed.id, genre = feed.genre).take(10)
if (feed.transportUrl.startsWith("cs3://")) {
fetchCs3FeedItems(feed).take(10)
} else {
addonRepository.getAddonCatalog(feed.transportUrl, feed.type, feed.id, genre = feed.genre).take(10)
}
}
}
.awaitAll()

View file

@ -101,10 +101,14 @@ internal fun formatElapsedTime(ms: Long, lang: String? = "en"): String {
}
internal fun parseShortVideoId(id: String?): String? {
if (id == null) return null
if (id.isNullOrBlank() || id.startsWith("cs3:")) return null
val parts = id.split(":")
if (parts.size >= 3) {
return "S${parts[parts.size - 2]}, E${parts[parts.size - 1]}"
val season = parts[parts.size - 2].toIntOrNull()
val episode = parts[parts.size - 1].toIntOrNull()
if (season != null && episode != null) {
return "S$season, E$episode"
}
}
return null
}

View file

@ -182,9 +182,11 @@ fun mobileHeroArtworkCandidates(meta: Meta, seasonPostersOnHero: Boolean = true)
?.takeUnless { it == meta.poster }
?.takeUnless { it.contains("/poster/", ignoreCase = true) }
?.takeUnless { !seasonPostersOnHero && it == seasonPoster }
val posterFallback = meta.poster?.takeIf { it.isNotBlank() }
return buildList {
seasonPoster?.takeIf { seasonPostersOnHero }?.let(::add)
existingBackdrop?.let(::add)
posterFallback?.let(::add)
}.distinct()
}

View file

@ -36,8 +36,10 @@ data class Stream(
headers[k.toString()] = v.toString()
}
}
// Direct referer hint
(hints["referer"] as? String)?.let { headers["Referer"] = it }
// Direct referer hint. Cloudstream sends this lower-case through Media3 request properties.
(hints["referer"] as? String)
?.takeIf { it.isNotBlank() }
?.let { headers["referer"] = it }
}
return headers
}

View file

@ -26,6 +26,12 @@ data class DiscoverCatalogOption(
val requiresGenre: Boolean = false
)
data class Cs3CatalogFeedDescriptor(
val pluginName: String,
val catalogName: String,
val catalogIndex: Int
)
fun buildMetadataFeedOptions(addons: List<AddonDescriptor>, language: String? = "en"): List<MetadataFeedOption> {
val addonFeeds = addons
.flatMap { addon -> addon.toMetadataFeedOptions() }
@ -99,14 +105,18 @@ private fun discoverCatalogLabel(rawName: String?, id: String): String {
fun cs3PluginFeedKey(apiName: String): String =
"cs3_plugin_${apiName.replace(Regex("[^a-zA-Z0-9]"), "_").lowercase()}"
fun buildCs3MetadataFeedOptions(apiNames: List<String>): List<MetadataFeedOption> =
apiNames.map { name ->
fun cs3CatalogFeedKey(pluginName: String, catalogName: String, catalogIndex: Int): String =
"cs3_catalog_${pluginName.stableFeedPart()}:${catalogIndex}:${catalogName.stableFeedPart()}"
fun buildCs3MetadataFeedOptions(catalogs: List<Cs3CatalogFeedDescriptor>): List<MetadataFeedOption> =
catalogs.map { catalog ->
val key = cs3CatalogFeedKey(catalog.pluginName, catalog.catalogName, catalog.catalogIndex)
MetadataFeedOption(
key = cs3PluginFeedKey(name),
label = name,
transportUrl = "cs3://${cs3PluginFeedKey(name)}",
key = key,
label = "${catalog.catalogName} - ${catalog.pluginName}",
transportUrl = "cs3://$key",
type = "all",
id = cs3PluginFeedKey(name)
id = key
)
}