diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt index 8ec082520fb..cce3555a510 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt @@ -154,6 +154,7 @@ class APIRepository(val api: MainAPI) { } suspend fun getMainPage(page: Int, nameIndex: Int? = null): Resource> { + if (api is com.lagradost.cloudstream3.ui.home.CombinedHomeApi) return api.getPages(page, nameIndex) return safeApiCall { withTimeout(getTimeout(api.getMainPageTimeoutMs)) { api.lastHomepageRequest = unixTimeMS diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/CombinedHomeApi.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/CombinedHomeApi.kt new file mode 100644 index 00000000000..92ff82e72bf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/CombinedHomeApi.kt @@ -0,0 +1,83 @@ +package com.lagradost.cloudstream3.ui.home + +import com.lagradost.cloudstream3.HomePageResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.MainPageData +import com.lagradost.cloudstream3.MainPageRequest +import com.lagradost.cloudstream3.newHomePageResponse +import com.lagradost.cloudstream3.mvvm.Resource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap + +internal class CombinedHomeApi(providers: List) : MainAPI() { + companion object { const val NAME = "Selected sources" } + private data class Page(val provider: MainAPI, val data: MainPageData) + private val pages = providers.flatMap { provider -> provider.mainPage.map { Page(provider, it) } } + private val rowOwners = ConcurrentHashMap() + private val limits = Semaphore(3) + var onPartial: ((List) -> Unit)? = null + private val providerLocks = providers.associateWith { Mutex() } + override var name = NAME + override var mainUrl = "" + override val hasMainPage = true + override val mainPage = pages.mapIndexed { index, page -> + MainPageData("${page.provider.name} · ${page.data.name}", index.toString(), page.data.horizontalImages) + } + + fun pageIndexForRow(name: String): Int? = rowOwners[name] + + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + val index = request.data.toIntOrNull() ?: return newHomePageResponse(emptyList(), false) + val owner = pages.getOrNull(index) ?: return newHomePageResponse(emptyList(), false) + return providerLocks.getValue(owner.provider).withLock { + limits.withPermit { + try { + val response = withTimeoutOrNull(20_000) { + val api = owner.provider + val pause = api.lastHomepageRequest + + maxOf(api.sequentialMainPageScrollDelay, if (api.sequentialMainPage) api.sequentialMainPageDelay else 0) - + com.lagradost.cloudstream3.APIHolder.unixTimeMS + if (pause > 0) delay(pause) + api.lastHomepageRequest = com.lagradost.cloudstream3.APIHolder.unixTimeMS + api.getMainPage(page, MainPageRequest(owner.data.name, owner.data.data, owner.data.horizontalImages)) + } ?: return@withPermit newHomePageResponse(emptyList(), false) + newHomePageResponse(response.items.map { row -> + val rowName = if (row.name == owner.data.name) "${owner.provider.name} · ${row.name}" + else "${owner.provider.name} · ${owner.data.name}: ${row.name}" + rowOwners[rowName] = index + row.copy(name = rowName) + }, response.hasNext) + } catch (cancelled: CancellationException) { throw cancelled } + catch (_: Exception) { newHomePageResponse(emptyList(), false) } + } + } + } + + suspend fun getPages(page: Int, index: Int?): Resource> = coroutineScope { + val results = ConcurrentHashMap() + withTimeoutOrNull(35_000) { + (index?.let { listOf(it) } ?: pages.indices.toList()).map { position -> + async { + mainPage.getOrNull(position)?.let { data -> + val result = getMainPage(page, MainPageRequest(data.name, data.data, data.horizontalImages)) + if (result.items.isNotEmpty()) synchronized(results) { + results[position] = result + onPartial?.invoke(results.toSortedMap().values.toList()) + } + } + } + }.awaitAll() + } + if (results.isEmpty()) Resource.Failure(false, "No selected source returned a catalogue") + else Resource.Success(results.toSortedMap().values.toList()) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt index 04f8b0b26ff..943842e26d7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt @@ -399,6 +399,10 @@ class HomeFragment : BaseFragment( ) builder.setContentView(binding.root) + binding.multipleSourcesBtt.setOnClickListener { + builder.dismissSafe() + MultipleHomeSources.show(this, selectedApiName, callback) + } builder.show() builder.let { dialog -> val isMultiLang = getApiProviderLangSettings().let { set -> diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt index 8d48f5a6859..f1d76fd0e37 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt @@ -53,6 +53,13 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.withContext import java.util.EnumSet import java.util.concurrent.CopyOnWriteArrayList @@ -244,7 +251,10 @@ class HomeViewModel : ViewModel() { } val nextPage = current.currentPage + 1 - val next = getMainPage(nextPage, mainPage.indexOfFirst { it.name == name }) + val pageIndex = (api as? CombinedHomeApi)?.pageIndexForRow(name) + ?: mainPage.indexOfFirst { it.name == name } + if (pageIndex < 0) { lock -= name; return null } + val next = getMainPage(nextPage, pageIndex) if (next is Resource.Success) { next.value.filterNotNull().forEach { main -> main.items.forEach { newList -> @@ -301,15 +311,28 @@ class HomeViewModel : ViewModel() { } } - val add = addItems.amap { searchResponse -> - repo?.load(searchResponse.url) - }.mapNotNull { if (it != null && it is Resource.Success) it.value else null } + val completed = ConcurrentHashMap() + val permits = Semaphore(2) + withTimeoutOrNull(18_000) { + coroutineScope { + addItems.mapIndexed { index, searchResponse -> async { + permits.withPermit { + val response = withTimeoutOrNull(8_000) { + getApiFromNameNull(searchResponse.apiName)?.let { APIRepository(it).load(searchResponse.url) } + } + if (response is Resource.Success) completed[index] = response.value + } + } }.awaitAll() + } + } + val add = completed.toSortedMap().values.toList() current.addAll(add) return add.size } private var addJob: Job? = null fun loadMoreHomeScrollResponses() { + addJob?.cancel() addJob = ioSafe { updatePreviewResponses(previewResponses, previewResponsesAdded, currentShuffledList, 1) _preview.postValue(Resource.Success((previewResponsesAdded.size < currentShuffledList.size) to previewResponses)) @@ -317,6 +340,16 @@ class HomeViewModel : ViewModel() { } private fun load(api: MainAPI): Job = ioSafe { + if (api is CombinedHomeApi) api.onPartial = { responses -> + if (repo?.api === api) { + val partial = linkedMapOf() + responses.forEach { response -> response.items.forEach { row -> + val filtered = context?.filterHomePageListByFilmQuality(row) ?: row + partial[row.name] = ExpandableHomepageList(filtered, 1, false) + } } + _page.postValue(Resource.Success(partial)) + } + } repo = //if (api != null) { APIRepository(api) //} else { @@ -357,6 +390,7 @@ class HomeViewModel : ViewModel() { } } + _page.postValue(Resource.Success(expandable.toMap())) val items = data.value.mapNotNull { it?.items }.flatten() @@ -517,6 +551,13 @@ class HomeViewModel : ViewModel() { } val api = getApiFromNameNull(preferredApiName) + if (preferredApiName == CombinedHomeApi.NAME) { + val providers = context?.let(MultipleHomeSources::selected).orEmpty() + if (fromUI) DataStoreHelper.currentHomePage = CombinedHomeApi.NAME + loadAndCancel(if (providers.isEmpty()) noneApi else CombinedHomeApi(providers)) + reloadAccount() + return@ioSafe + } if (preferredApiName == noneApi.name) { // just set to random if (fromUI) DataStoreHelper.currentHomePage = noneApi.name @@ -549,4 +590,4 @@ class HomeViewModel : ViewModel() { } reloadAccount() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/home/MultipleHomeSources.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/home/MultipleHomeSources.kt new file mode 100644 index 00000000000..26eb8fd3f00 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/home/MultipleHomeSources.kt @@ -0,0 +1,43 @@ +package com.lagradost.cloudstream3.ui.home + +import android.content.Context +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit +import androidx.preference.PreferenceManager +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.ui.APIRepository +import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia + +internal object MultipleHomeSources { + fun active(context: Context): List { + val current = com.lagradost.cloudstream3.utils.DataStoreHelper.currentHomePage + return if (current == CombinedHomeApi.NAME) selected(context) + else listOfNotNull(com.lagradost.cloudstream3.APIHolder.getApiFromNameNull(current)).filter { it.hasMainPage } + } + fun selected(context: Context): List { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val names = prefs.getStringSet("home_selected_sources", emptySet()).orEmpty() + return context.filterProviderByPreferredMedia().filter { + it.hasMainPage && (prefs.getBoolean("home_all_sources", false) || it.name in names) + }.distinctBy { it.name } + } + + fun show(context: Context, current: String?, callback: (String) -> Unit) { + val providers = context.filterProviderByPreferredMedia().filter { it.hasMainPage }.distinctBy { it.name } + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val saved = prefs.getStringSet("home_selected_sources", current?.let { setOf(it) } ?: emptySet()).orEmpty() + val chosen = if (prefs.getBoolean("home_all_sources", false)) providers.map { it.name }.toMutableSet() else saved.toMutableSet() + val names = providers.map { it.name }.toTypedArray() + fun apply(all: Boolean) { + prefs.edit { putBoolean("home_all_sources", all); putStringSet("home_selected_sources", chosen.toSet()) } + callback(if (chosen.isEmpty()) APIRepository.noneApi.name else CombinedHomeApi.NAME) + } + AlertDialog.Builder(context).setTitle(R.string.home_multiple_sources) + .setMultiChoiceItems(names, BooleanArray(names.size) { names[it] in chosen }) { _, index, checked -> + if (checked) chosen.add(names[index]) else chosen.remove(names[index]) + }.setPositiveButton(R.string.sort_apply) { _, _ -> apply(false) } + .setNeutralButton(R.string.home_all_sources) { _, _ -> chosen.addAll(names); apply(true) } + .setNegativeButton(android.R.string.cancel, null).show() + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ProviderRecommendations.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ProviderRecommendations.kt new file mode 100644 index 00000000000..42732b0a932 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ProviderRecommendations.kt @@ -0,0 +1,64 @@ +package com.lagradost.cloudstream3.ui.result + +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.ProviderType +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.ui.APIRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import kotlinx.coroutines.withTimeoutOrNull +import java.text.Normalizer + +internal object ProviderRecommendations { + private fun title(value: String) = Normalizer.normalize(value, Normalizer.Form.NFD) + .replace("\\p{M}+".toRegex(), "").lowercase().replace("[^\\p{L}\\p{N}]".toRegex(), "") + suspend fun collect(item: LoadResponse, providers: List, limit: Int, emit: (List) -> Unit) = coroutineScope { + val allowed = providers.map { it.name }.toSet() + val results = linkedMapOf() + val gate = Any() + fun add(items: List) = synchronized(gate) { + items.filter { (allowed.isEmpty() || it.apiName in allowed) && title(it.name) != title(item.name) }.forEach { + if (results.size < limit) results.putIfAbsent(title(it.name) + ":" + it.type, it) + } + emit(results.values.toList()) + } + add(item.recommendations.orEmpty()) + val permits = Semaphore(2) + withTimeoutOrNull(18_000) { + providers.map { provider -> async { + permits.withPermit { + if (synchronized(gate) { results.size >= limit }) return@withPermit + withTimeoutOrNull(8_000) { + val repo = APIRepository(provider) + if (provider.name != item.apiName && provider.providerType == ProviderType.DirectProvider) { + val search = repo.search(item.name, 1) + currentCoroutineContext().ensureActive() + if (search is Resource.Success) { + val match = search.value.items.firstOrNull { title(it.name) == title(item.name) } + if (match != null) { + val loaded = repo.load(match.url) + currentCoroutineContext().ensureActive() + if (loaded is Resource.Success && (item.year == null || loaded.value.year == null || item.year == loaded.value.year)) { + add(loaded.value.recommendations.orEmpty()) + } + } + } + } + if (synchronized(gate) { results.size < limit }) { + val home = repo.getMainPage(1, 0) + currentCoroutineContext().ensureActive() + if (home is Resource.Success) add(home.value.filterNotNull().flatMap { it.items }.flatMap { it.list }.filter { it.type == item.type }) + } + } + } + } }.awaitAll() + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt index 38b24b26517..eb14a0eb742 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt @@ -1449,13 +1449,13 @@ open class ResultFragmentPhone : BaseFragment( private fun setRecommendations(rec: List?, validApiName: String?) { val isInvalid = rec.isNullOrEmpty() - val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName + val matchAgainst = validApiName recommendationBinding?.apply { root.isGone = isInvalid root.post { rec?.let { list -> - (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(list.filter { it.apiName == matchAgainst }) + (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(list.filter { matchAgainst == null || it.apiName == matchAgainst }.take(12)) } } } @@ -1483,14 +1483,14 @@ open class ResultFragmentPhone : BaseFragment( // very dirty selection recommendationBinding?.resultRecommendationsFilterButton?.apply { isVisible = apiNames.size > 1 - text = matchAgainst + text = matchAgainst ?: getString(R.string.recommendations_all_sources) setOnClickListener { _ -> activity?.showBottomDialog( - apiNames, - apiNames.indexOf(matchAgainst), + listOf(getString(R.string.recommendations_all_sources)) + apiNames, + if (matchAgainst == null) 0 else apiNames.indexOf(matchAgainst) + 1, getString(R.string.home_change_provider_img_des), false, {} ) { - setRecommendations(rec, apiNames[it]) + setRecommendations(rec, if (it == 0) null else apiNames[it - 1]) } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt index c316fae1715..d7ca0afdef0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt @@ -155,19 +155,19 @@ class ResultFragmentTv : BaseFragment( binding?.apply { resultRecommendationsList.isGone = isInvalid resultRecommendationsHolder.isGone = isInvalid - val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName - (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(rec?.filter { it.apiName == matchAgainst } + val matchAgainst = validApiName?.takeUnless { it == "__all_selected_sources__" } + (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(rec?.filter { matchAgainst == null || it.apiName == matchAgainst }?.take(12) ?: emptyList()) rec?.map { it.apiName }?.distinct()?.let { apiNames -> // very dirty selection resultRecommendationsFilterSelection.isVisible = apiNames.size > 1 - resultRecommendationsFilterSelection.update(apiNames.map { + resultRecommendationsFilterSelection.update(listOf(txt(R.string.recommendations_all_sources) to "__all_selected_sources__") + apiNames.map { txt( it ) to it }) - resultRecommendationsFilterSelection.select(apiNames.indexOf(matchAgainst)) + resultRecommendationsFilterSelection.select(if (matchAgainst == null) 0 else apiNames.indexOf(matchAgainst) + 1) } ?: run { resultRecommendationsFilterSelection.isVisible = false } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt index 17a698de2df..030ebaded2e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt @@ -139,6 +139,7 @@ import com.lagradost.cloudstream3.utils.newExtractorLink import com.lagradost.cloudstream3.utils.txt import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.coroutineScope @@ -2503,8 +2504,25 @@ class ResultViewModel2 : ViewModel() { // this instantly updates the metadata on the page + private var recommendationJob: Job? = null + private var recommendationKey: String? = null + private fun postPage(loadResponse: LoadResponse, apiRepository: APIRepository) { - _recommendations.postValue(loadResponse.recommendations ?: emptyList()) + val sources = context?.let(com.lagradost.cloudstream3.ui.home.MultipleHomeSources::active).orEmpty() + val limit = context?.let { androidx.preference.PreferenceManager.getDefaultSharedPreferences(it) + .getString("recommendation_count", "12")?.toIntOrNull()?.coerceIn(6, 12) } ?: 12 + val key = loadResponse.apiName + loadResponse.url + sources.joinToString { it.name } + limit + if (key != recommendationKey) { + recommendationJob?.cancel() + recommendationKey = key + _recommendations.postValue(loadResponse.recommendations.orEmpty() + .filter { sources.isEmpty() || sources.any { api -> api.name == it.apiName } }.take(limit)) + recommendationJob = viewModelScope.launchSafe(Dispatchers.IO) { + ProviderRecommendations.collect(loadResponse, sources, limit) { recommendations -> + if (recommendationKey == key) _recommendations.postValue(recommendations) + } + } + } _page.postValue(Resource.Success(loadResponse.toResultData(apiRepository))) } diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt index 6aa94fa883c..f94cb67ea6d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneralScreen.kt @@ -114,6 +114,16 @@ object SettingsGeneralScreen : SearchableSettings { }.sortedBy { fromTagToLanguageName(it) ?: it } return persistentListOf( + Preference.PreferenceGroup( + title = stringResource(R.string.catalogue_options_title), + preferenceItems = persistentListOf( + Preference.PreferenceItem.ListPreference( + preference = settings.general.recommendationCount, + title = stringResource(R.string.recommendation_count_title), + entries = mapOf("6" to "6", "12" to "12"), + ), + ), + ), Preference.PreferenceGroup(title = stringResource(R.string.extension_language), preferenceItems = persistentListOf( Preference.PreferenceItem.ListPreference( preference = settings.general.locale, diff --git a/app/src/main/res/layout/home_select_mainpage.xml b/app/src/main/res/layout/home_select_mainpage.xml index dc47b786e88..014987fa467 100644 --- a/app/src/main/res/layout/home_select_mainpage.xml +++ b/app/src/main/res/layout/home_select_mainpage.xml @@ -11,7 +11,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_rowWeight="1" - android:layout_marginTop="10dp" + android:layout_marginTop="60dp" android:layout_marginBottom="60dp" android:clipToPadding="false" android:minHeight="0dp" @@ -20,6 +20,10 @@ android:nextFocusRight="@id/cancel_btt" android:requiresFadingEdge="vertical" tools:listitem="@layout/sort_bottom_single_provider_choice" /> + - \ No newline at end of file + diff --git a/app/src/main/res/values-es/catalogue_options.xml b/app/src/main/res/values-es/catalogue_options.xml new file mode 100644 index 00000000000..5dee14b8f2a --- /dev/null +++ b/app/src/main/res/values-es/catalogue_options.xml @@ -0,0 +1,7 @@ + + Fuentes de Inicio y recomendaciones + Todas las fuentes seleccionadas + Recomendaciones por título + Seleccionar varias fuentes + Todas las fuentes compatibles + diff --git a/app/src/main/res/values/catalogue_options.xml b/app/src/main/res/values/catalogue_options.xml new file mode 100644 index 00000000000..ae1576446d8 --- /dev/null +++ b/app/src/main/res/values/catalogue_options.xml @@ -0,0 +1,8 @@ + + Home sources and recommendations + All selected sources + Recommendations per title + 612 + Select multiple sources + All compatible sources + diff --git a/app/src/main/res/xml/settings_general.xml b/app/src/main/res/xml/settings_general.xml index f8b9b4f452f..46e133582ba 100644 --- a/app/src/main/res/xml/settings_general.xml +++ b/app/src/main/res/xml/settings_general.xml @@ -1,6 +1,9 @@ + + + >() + api.onPartial = { snapshots.add(it.flatMap { page -> page.items }.map { it.name }) } + val result = api.getPages(1,null) as Resource.Success + assertEquals(listOf("Fast · Movies"), snapshots.first()) + assertEquals(listOf("Slow · Movies","Fast · Movies"),result.value.filterNotNull().flatMap { it.items }.map { it.name }) + } + + @Test fun cancellationStopsPendingSources() = runBlocking { + val api = CombinedHomeApi(listOf(source("Slow",60000))) + val job = async { api.getPages(1,null) } + yield() + job.cancelAndJoin() + assertTrue(job.isCancelled) + } + + @Test fun capsConcurrentProvidersAtThree() = runBlocking { + val running = AtomicInteger() + val maximum = AtomicInteger() + val providers = (1..8).map { index -> object : MainAPI() { + override var name = "Source $index" + override val hasMainPage = true + override val mainPage = listOf(MainPageData("Movies", "/movies")) + override suspend fun getMainPage(page: Int, request: MainPageRequest): HomePageResponse { + val count = running.incrementAndGet() + maximum.accumulateAndGet(count, ::maxOf) + try { delay(10); return newHomePageResponse(request,emptyList(),false) } + finally { running.decrementAndGet() } + } + } } + CombinedHomeApi(providers).getPages(1,null) + assertTrue(maximum.get() in 2..3) + assertEquals(0,running.get()) + } +} diff --git a/app/src/test/java/com/lagradost/cloudstream3/ui/result/ProviderRecommendationsTest.kt b/app/src/test/java/com/lagradost/cloudstream3/ui/result/ProviderRecommendationsTest.kt new file mode 100644 index 00000000000..b023f2f8ec9 --- /dev/null +++ b/app/src/test/java/com/lagradost/cloudstream3/ui/result/ProviderRecommendationsTest.kt @@ -0,0 +1,43 @@ +package com.lagradost.cloudstream3.ui.result + +import com.lagradost.cloudstream3.* +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test + +class ProviderRecommendationsTest { + private val source = object : MainAPI() { override var name = "Selected" } + private val other = object : MainAPI() { override var name = "Other" } + + @Test fun keepsTwelveSelectedRecommendationsAndExcludesCurrentAndForeignTitles() = runBlocking { + val item = source.newMovieLoadResponse("Current", "https://example.com/current", TvType.Movie, "video") { + recommendations = listOf(source.newMovieSearchResponse("Current", "https://example.com/current", fix=false), + other.newMovieSearchResponse("Foreign", "https://example.com/foreign", fix=false)) + + (1..14).map { source.newMovieSearchResponse("Film $it", "https://example.com/$it", fix=false) } + } + var result = emptyList() + ProviderRecommendations.collect(item,listOf(source),12) { result = it } + assertEquals(12,result.size) + assertTrue(result.all { it.apiName == source.name }) + assertFalse(result.any { it.name == "Current" || it.name == "Foreign" }) + } + + @Test fun retainsSixOptionAndDeduplicatesEquivalentTitles() = runBlocking { + val item = source.newMovieLoadResponse("Current", "https://example.com/current", TvType.Movie, "video") { + recommendations = listOf("Película!", "Pelicula") .mapIndexed { index, name -> + source.newMovieSearchResponse(name,"https://example.com/duplicate$index", fix=false) + } + (1..8).map { source.newMovieSearchResponse("Film $it","https://example.com/$it",fix=false) } + } + var result = emptyList() + ProviderRecommendations.collect(item,listOf(source),6) { result = it } + assertEquals(6,result.size) + assertEquals(1,result.count { it.name == "Película!" || it.name == "Pelicula" }) + } + + @Test fun doesNotFabricateMissingRecommendations() = runBlocking { + val item = source.newMovieLoadResponse("Current", "https://example.com/current", TvType.Movie, "video") + var result = emptyList() + ProviderRecommendations.collect(item,emptyList(),12) { result = it } + assertTrue(result.isEmpty()) + } +} diff --git a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt index cad05c42229..d35247421c7 100644 --- a/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt +++ b/shared/src/commonMain/kotlin/com/lagradost/cloudstream4/DevicePreferenceStore.kt @@ -189,6 +189,7 @@ class PlayerPreferences(preferences: PreferenceStore) { } class GeneralPreferences(preferences: PreferenceStore) { + val recommendationCount = preferences.getString("recommendation_count", "12") val locale = preferences.getString("app_locale", "") val bananas = preferences.getInt("benene_count", 0) val parallelDownloads = preferences.getInt("download_parallel_key", 3)