mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-03 23:46:54 +00:00
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:
parent
05083b3f0b
commit
b91c91f205
7 changed files with 158 additions and 25 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue