diff --git a/app/src/main/java/com/fluxa/app/data/repository/CloudStreamCatalogClient.kt b/app/src/main/java/com/fluxa/app/data/repository/CloudStreamCatalogClient.kt index 6057586..68ef336 100644 --- a/app/src/main/java/com/fluxa/app/data/repository/CloudStreamCatalogClient.kt +++ b/app/src/main/java/com/fluxa/app/data/repository/CloudStreamCatalogClient.kt @@ -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, - iconsByApiName: Map = emptyMap() + iconsByApiName: Map = emptyMap(), + enabledFeedKeys: Set? = null ): List = 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 { + suspend fun fetchFeedItems( + apis: List, + feedKey: String, + page: Int = 1 + ): List = + 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, + query: String + ): List = + 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? = null + ): List { val result = mutableListOf() 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 { + 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) diff --git a/app/src/main/java/com/fluxa/app/data/repository/CloudStreamDiscoveryClient.kt b/app/src/main/java/com/fluxa/app/data/repository/CloudStreamDiscoveryClient.kt index 975924a..01a3ae1 100644 --- a/app/src/main/java/com/fluxa/app/data/repository/CloudStreamDiscoveryClient.kt +++ b/app/src/main/java/com/fluxa/app/data/repository/CloudStreamDiscoveryClient.kt @@ -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) = 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()) } diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeBillboardLoader.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeBillboardLoader.kt index 4e6d5d1..f20344c 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeBillboardLoader.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeBillboardLoader.kt @@ -17,6 +17,8 @@ internal class HomeBillboardLoader( private val addonRepository: AddonRepository, private val scope: CoroutineScope, private val getMetadataFeeds: suspend (UserProfile?) -> List, + private val getCs3MetadataFeeds: () -> List, + private val fetchCs3FeedItems: suspend (MetadataFeedOption) -> List, private val setPool: (List) -> Unit, private val updateContent: suspend (Meta) -> Unit, private val normalizePool: (List) -> List, @@ -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() diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeContentFormatters.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeContentFormatters.kt index cb7320c..d2f5182 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeContentFormatters.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeContentFormatters.kt @@ -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 } diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/HomeLayoutHelpers.kt b/app/src/main/java/com/fluxa/app/ui/catalog/HomeLayoutHelpers.kt index 41ddf71..dc26182 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/HomeLayoutHelpers.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/HomeLayoutHelpers.kt @@ -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() } diff --git a/data/src/main/java/com/fluxa/app/data/remote/Stream.kt b/data/src/main/java/com/fluxa/app/data/remote/Stream.kt index 9558ebe..83fe993 100644 --- a/data/src/main/java/com/fluxa/app/data/remote/Stream.kt +++ b/data/src/main/java/com/fluxa/app/data/remote/Stream.kt @@ -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 } diff --git a/data/src/main/java/com/fluxa/app/domain/discovery/MetadataFeeds.kt b/data/src/main/java/com/fluxa/app/domain/discovery/MetadataFeeds.kt index b973701..8d6db3b 100644 --- a/data/src/main/java/com/fluxa/app/domain/discovery/MetadataFeeds.kt +++ b/data/src/main/java/com/fluxa/app/domain/discovery/MetadataFeeds.kt @@ -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, language: String? = "en"): List { 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): List = - apiNames.map { name -> +fun cs3CatalogFeedKey(pluginName: String, catalogName: String, catalogIndex: Int): String = + "cs3_catalog_${pluginName.stableFeedPart()}:${catalogIndex}:${catalogName.stableFeedPart()}" + +fun buildCs3MetadataFeedOptions(catalogs: List): List = + 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 ) }